Skip to main content

mimick/
diagnostics.rs

1//! Exports a support-friendly diagnostics bundle with sensitive local data redacted for privacy.
2//!
3//! Copies recent log files, state snapshots, and environment summaries
4//! into a temporary directory. API keys, URLs, and filesystem paths are
5//! scrubbed before the bundle is presented to the user for attachment
6//! to issue reports.
7
8use crate::config::Config;
9use crate::queue_manager::FileTask;
10use crate::state_manager::AppState;
11use serde::Serialize;
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17/// Primary entrypoint to create and export a redacted diagnostics zip/bundle folder.
18pub fn export_bundle(
19    destination_root: &Path,
20    state: &AppState,
21    config: &Config,
22) -> io::Result<PathBuf> {
23    let cache_root = crate::profile::cache_dir()
24        .unwrap_or_else(|| PathBuf::from("/tmp").join(crate::profile::dir_segment()));
25    let data_root = crate::profile::data_dir()
26        .unwrap_or_else(|| PathBuf::from("/tmp").join(crate::profile::dir_segment()));
27    export_bundle_with_paths(destination_root, state, config, &cache_root, &data_root)
28}
29
30/// Helper that generates redactions by specifying explicit caches and data roots for testing.
31fn export_bundle_with_paths(
32    destination_root: &Path,
33    state: &AppState,
34    config: &Config,
35    cache_root: &Path,
36    data_root: &Path,
37) -> io::Result<PathBuf> {
38    let timestamp = SystemTime::now()
39        .duration_since(UNIX_EPOCH)
40        .unwrap_or_default()
41        .as_secs();
42    let bundle_dir = destination_root.join(format!("mimick-diagnostics-{}", timestamp));
43    fs::create_dir_all(&bundle_dir)?;
44
45    let summary_path = bundle_dir.join("summary.txt");
46    fs::write(summary_path, build_summary(config, state))?;
47    fs::write(
48        bundle_dir.join("privacy-note.txt"),
49        "This bundle intentionally omits API keys, raw logs, server URLs, and full local paths.\n",
50    )?;
51
52    write_json_pretty(
53        &bundle_dir.join("config.redacted.json"),
54        &build_config_export(config),
55    )?;
56    write_json_pretty(
57        &bundle_dir.join("status.redacted.json"),
58        &build_state_export(state),
59    )?;
60    write_json_pretty(
61        &bundle_dir.join("retries.redacted.json"),
62        &build_retry_export(&cache_path(cache_root, "retries.json"))?,
63    )?;
64    write_json_pretty(
65        &bundle_dir.join("synced_index.redacted.json"),
66        &build_sync_index_export(&cache_path(data_root, "synced_index.json"))?,
67    )?;
68
69    Ok(bundle_dir)
70}
71
72/// Format system environment and configuration state as plain text.
73fn build_summary(config: &Config, state: &AppState) -> String {
74    let mut lines = Vec::new();
75    lines.push("Mimick diagnostics export".to_string());
76    lines.push(format!("Version: {}", env!("CARGO_PKG_VERSION")));
77    lines.push(format!("App status: {}", state.status));
78    lines.push(format!("Paused: {}", state.paused));
79    lines.push(format!(
80        "Pause reason: {}",
81        state.pause_reason.as_deref().unwrap_or("none")
82    ));
83    lines.push(format!(
84        "Watched folder count: {}",
85        state.watched_folder_count
86    ));
87    lines.push(format!(
88        "Active server route: {}",
89        state.active_server_route.as_deref().unwrap_or("none")
90    ));
91    lines.push(format!("Queue size: {}", state.queue_size));
92    lines.push(format!("Processed count: {}", state.processed_count));
93    lines.push(format!("Failed count: {}", state.failed_count));
94    lines.push(format!(
95        "Current file: {}",
96        state
97            .current_file
98            .as_deref()
99            .map(redact_path_hint)
100            .unwrap_or_else(|| "none".to_string())
101    ));
102    lines.push(format!(
103        "Last completed file: {}",
104        state
105            .last_completed_file
106            .as_deref()
107            .map(redact_path_hint)
108            .unwrap_or_else(|| "none".to_string())
109    ));
110    lines.push(format!(
111        "Last error: {}",
112        state.last_error.as_deref().unwrap_or("none")
113    ));
114    lines.push(format!(
115        "Suggested fix: {}",
116        state.last_error_guidance.as_deref().unwrap_or("none")
117    ));
118    lines.push(format!(
119        "Configured watch paths: {}",
120        config.data.watch_paths.len()
121    ));
122    lines.push(format!(
123        "Pause on metered network: {}",
124        config.data.pause_on_metered_network
125    ));
126    lines.push(format!(
127        "Pause on battery power: {}",
128        config.data.pause_on_battery_power
129    ));
130    lines.push(format!(
131        "Background sync enabled: {}",
132        config.data.background_sync_enabled
133    ));
134    lines.push(format!(
135        "Notifications enabled: {}",
136        config.data.notifications_enabled
137    ));
138    lines.push(format!(
139        "Startup catchup mode: {:?}",
140        config.data.startup_catchup_mode
141    ));
142    lines.push(format!(
143        "Upload concurrency: {}",
144        config.data.upload_concurrency
145    ));
146    lines.push(format!(
147        "Quiet hours start: {}",
148        config
149            .data
150            .quiet_hours_start
151            .map(|h| h.to_string())
152            .unwrap_or_else(|| "disabled".to_string())
153    ));
154    lines.push(format!(
155        "Quiet hours end: {}",
156        config
157            .data
158            .quiet_hours_end
159            .map(|h| h.to_string())
160            .unwrap_or_else(|| "disabled".to_string())
161    ));
162    lines.push(
163        "Sensitive data policy: URLs, API key, logs, and full local paths omitted".to_string(),
164    );
165    lines.push(String::new());
166    lines.push("Recent queue events:".to_string());
167    for event in &state.recent_events {
168        lines.push(format!(
169            "- {} [{}] attempts={} detail={}",
170            redact_path_hint(&event.path),
171            event.status,
172            event.attempts,
173            event.detail.as_deref().unwrap_or("none")
174        ));
175    }
176
177    lines.join("\n")
178}
179
180/// Resolve a named file within the system cache root.
181fn cache_path(cache_root: &Path, name: &str) -> PathBuf {
182    cache_root.join(name)
183}
184
185/// Safe, fully redacted structural copy of Config for user privacy.
186#[derive(Serialize)]
187struct RedactedConfigExport {
188    /// True if internal URL is actively configured.
189    internal_url_enabled: bool,
190    /// True if external URL is actively configured.
191    external_url_enabled: bool,
192    /// Number of configured watch paths.
193    watch_path_count: usize,
194    /// Number of watch paths targeting custom albums.
195    watch_paths_with_custom_album: usize,
196    /// Number of watch paths using extension or size filters.
197    watch_paths_with_rules: usize,
198    /// True if autostart/startup is enabled.
199    run_on_startup: bool,
200    /// True if transfer pauses on metered network.
201    pause_on_metered_network: bool,
202    /// True if transfer pauses when running on battery.
203    pause_on_battery_power: bool,
204    /// True if filesystem watcher is active in background.
205    background_sync_enabled: bool,
206    /// True if user notifications are enabled.
207    notifications_enabled: bool,
208    /// Current catchup strategy mode string representation.
209    startup_catchup_mode: String,
210    /// Number of parallel uploads allowed.
211    upload_concurrency: u8,
212    /// Hour of the day when quiet window begins, if any.
213    quiet_hours_start: Option<u8>,
214    /// Hour of the day when quiet window ends, if any.
215    quiet_hours_end: Option<u8>,
216}
217
218/// Redacted view of a transfer event to avoid exposing private filenames.
219#[derive(Serialize)]
220struct RedactedQueueEvent {
221    /// Redacted filename suffix without path components.
222    path_hint: String,
223    /// Operation outcome status string.
224    status: String,
225    /// Detailed diagnostic warning or cause if failed.
226    detail: Option<String>,
227    /// Number of transfer attempts.
228    attempts: u32,
229    /// Timestamp when this event took place.
230    timestamp: f64,
231}
232
233/// Redacted snapshot of persistent app context status.
234#[derive(Serialize)]
235struct RedactedStateExport {
236    /// App status label.
237    status: String,
238    /// True if transfers are currently paused.
239    paused: bool,
240    /// User or environmental cause of pause.
241    pause_reason: Option<String>,
242    /// Number of directories being watched.
243    watched_folder_count: usize,
244    /// Connection route label.
245    active_server_route: Option<String>,
246    /// Number of active items in transmission queue.
247    queue_size: usize,
248    /// Total number of items queued this session.
249    total_queued: usize,
250    /// Number of successfully processed items.
251    processed_count: usize,
252    /// Number of items that failed transmission.
253    failed_count: usize,
254    /// Percent progress of active batch.
255    progress: u8,
256    /// Unix timestamp of last successful remote sync.
257    last_successful_sync_at: Option<f64>,
258    /// File currently being processed.
259    current_file: Option<String>,
260    /// Last processed file.
261    last_completed_file: Option<String>,
262    /// Last recorded network or logic error string.
263    last_error: Option<String>,
264    /// Troubleshooting instructions for the last error.
265    last_error_guidance: Option<String>,
266    /// Total count of diagnostic exports generated.
267    diagnostics_exports: usize,
268    /// Log of recent queue event summaries.
269    recent_events: Vec<RedactedQueueEvent>,
270}
271
272/// Quantitative summaries of files waiting to be retried.
273#[derive(Serialize)]
274struct RedactedRetryExport {
275    /// Total number of tasks pending retry.
276    total_retry_items: usize,
277    /// Number of tasks requiring only metadata linking.
278    reassociate_only_items: usize,
279    /// Number of tasks destined for specific albums.
280    album_targeted_items: usize,
281}
282
283/// Redacted metrics from the local synced database index.
284#[derive(Serialize)]
285struct RedactedSyncIndexExport {
286    /// Total synced records.
287    total_entries: usize,
288    /// Synced records belonging to named albums.
289    album_named_entries: usize,
290    /// Synced records with verified album IDs.
291    album_id_entries: usize,
292}
293
294/// Populate redacted configuration statistics from live config values.
295fn build_config_export(config: &Config) -> RedactedConfigExport {
296    let watch_paths_with_custom_album = config
297        .data
298        .watch_paths
299        .iter()
300        .filter(|entry| entry.album_name().is_some_and(|name| !name.is_empty()))
301        .count();
302    let watch_paths_with_rules = config
303        .data
304        .watch_paths
305        .iter()
306        .filter(|entry| {
307            let rules = entry.rules();
308            rules.ignore_hidden
309                || rules.max_file_size_mb.is_some()
310                || !rules.allowed_extensions.is_empty()
311        })
312        .count();
313
314    RedactedConfigExport {
315        internal_url_enabled: config.data.internal_url_enabled,
316        external_url_enabled: config.data.external_url_enabled,
317        watch_path_count: config.data.watch_paths.len(),
318        watch_paths_with_custom_album,
319        watch_paths_with_rules,
320        run_on_startup: config.data.run_on_startup,
321        pause_on_metered_network: config.data.pause_on_metered_network,
322        pause_on_battery_power: config.data.pause_on_battery_power,
323        background_sync_enabled: config.data.background_sync_enabled,
324        notifications_enabled: config.data.notifications_enabled,
325        startup_catchup_mode: format!("{:?}", config.data.startup_catchup_mode),
326        upload_concurrency: config.data.upload_concurrency,
327        quiet_hours_start: config.data.quiet_hours_start,
328        quiet_hours_end: config.data.quiet_hours_end,
329    }
330}
331
332/// Map live state values into redacted serialization format.
333fn build_state_export(state: &AppState) -> RedactedStateExport {
334    RedactedStateExport {
335        status: state.status.clone(),
336        paused: state.paused,
337        pause_reason: state.pause_reason.clone(),
338        watched_folder_count: state.watched_folder_count,
339        active_server_route: state.active_server_route.clone(),
340        queue_size: state.queue_size,
341        total_queued: state.total_queued,
342        processed_count: state.processed_count,
343        failed_count: state.failed_count,
344        progress: state.progress,
345        last_successful_sync_at: state.last_successful_sync_at,
346        current_file: state.current_file.as_deref().map(redact_path_hint),
347        last_completed_file: state.last_completed_file.as_deref().map(redact_path_hint),
348        last_error: state.last_error.clone(),
349        last_error_guidance: state.last_error_guidance.clone(),
350        diagnostics_exports: state.diagnostics_exports,
351        recent_events: state
352            .recent_events
353            .iter()
354            .map(|event| RedactedQueueEvent {
355                path_hint: redact_path_hint(&event.path),
356                status: event.status.clone(),
357                detail: event.detail.clone(),
358                attempts: event.attempts,
359                timestamp: event.timestamp,
360            })
361            .collect(),
362    }
363}
364
365/// Summarize items in the retry log without revealing their names.
366fn build_retry_export(retry_path: &Path) -> io::Result<RedactedRetryExport> {
367    let tasks = if retry_path.exists() {
368        let content = fs::read_to_string(retry_path)?;
369        serde_json::from_str::<Vec<FileTask>>(&content).unwrap_or_default()
370    } else {
371        Vec::new()
372    };
373
374    Ok(RedactedRetryExport {
375        total_retry_items: tasks.len(),
376        reassociate_only_items: tasks.iter().filter(|task| task.reassociate_only).count(),
377        album_targeted_items: tasks
378            .iter()
379            .filter(|task| task.album_id.is_some() || task.album_name.is_some())
380            .count(),
381    })
382}
383
384/// Summarize database index composition to avoid detailing private folder contents.
385fn build_sync_index_export(sync_index_path: &Path) -> io::Result<RedactedSyncIndexExport> {
386    if !sync_index_path.exists() {
387        return Ok(RedactedSyncIndexExport {
388            total_entries: 0,
389            album_named_entries: 0,
390            album_id_entries: 0,
391        });
392    }
393
394    let content = fs::read_to_string(sync_index_path)?;
395    let json = serde_json::from_str::<serde_json::Value>(&content).unwrap_or_default();
396    let files = json
397        .get("files")
398        .and_then(|files| files.as_object())
399        .cloned()
400        .unwrap_or_default();
401
402    let mut album_named_entries = 0usize;
403    let mut album_id_entries = 0usize;
404    for record in files.values() {
405        if record
406            .get("album_name")
407            .and_then(|value| value.as_str())
408            .is_some_and(|name| !name.is_empty())
409        {
410            album_named_entries += 1;
411        }
412        if record
413            .get("album_id")
414            .and_then(|value| value.as_str())
415            .is_some_and(|id| !id.is_empty())
416        {
417            album_id_entries += 1;
418        }
419    }
420
421    Ok(RedactedSyncIndexExport {
422        total_entries: files.len(),
423        album_named_entries,
424        album_id_entries,
425    })
426}
427
428/// Helper to write formatted, indented JSON structures to disk.
429fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
430    let content = serde_json::to_string_pretty(value)?;
431    fs::write(path, content)
432}
433
434/// Redact the absolute directory path, preserving only the trailing filename segment.
435fn redact_path_hint(path: &str) -> String {
436    Path::new(path)
437        .file_name()
438        .and_then(|name| name.to_str())
439        .filter(|name| !name.is_empty())
440        .map(|name| name.to_string())
441        .unwrap_or_else(|| "[path hidden]".to_string())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::{build_summary, export_bundle_with_paths};
447    use crate::config::{Config, ConfigData, WatchPathEntry};
448    use crate::state_manager::{AppState, QueueEvent};
449    use std::fs;
450    use std::path::PathBuf;
451    use tempfile::tempdir;
452
453    #[test]
454    fn test_build_summary_contains_recent_events_and_omits_api_key() {
455        let config = Config {
456            data: ConfigData {
457                watch_paths: vec![WatchPathEntry::Simple("/photos".into())],
458                pause_on_metered_network: true,
459                pause_on_battery_power: false,
460                ..ConfigData::default()
461            },
462            config_file: PathBuf::from("config.json"),
463        };
464        let mut state = AppState {
465            status: "paused".into(),
466            paused: true,
467            pause_reason: Some("Paused by user".into()),
468            ..AppState::default()
469        };
470        state.recent_events.push(QueueEvent {
471            path: "/photos/a.jpg".into(),
472            status: "failed".into(),
473            detail: Some("Queued for retry".into()),
474            attempts: 2,
475            timestamp: 1.0,
476        });
477
478        let summary = build_summary(&config, &state);
479        assert!(summary.contains("App status: paused"));
480        assert!(summary.contains("Watched folder count: 0"));
481        assert!(summary.contains("Configured watch paths: 1"));
482        assert!(
483            summary.contains(
484                "Sensitive data policy: URLs, API key, logs, and full local paths omitted"
485            )
486        );
487        assert!(summary.contains("a.jpg [failed] attempts=2"));
488        assert!(!summary.contains("/photos/a.jpg [failed] attempts=2"));
489    }
490
491    #[test]
492    fn test_export_bundle_writes_redacted_files_only() {
493        let dir = tempdir().unwrap();
494        let dest_root = dir.path().join("exports");
495        let cache_root = dir.path().join("cache");
496        let data_root = dir.path().join("data");
497        let config_root = dir.path().join("config");
498        fs::create_dir_all(&cache_root).unwrap();
499        fs::create_dir_all(&data_root).unwrap();
500        fs::create_dir_all(&config_root).unwrap();
501
502        let config_path = config_root.join("config.json");
503        fs::write(&config_path, "{\"internal_url\":\"http://localhost\"}").unwrap();
504        fs::write(cache_root.join("status.json"), "{\"status\":\"idle\"}").unwrap();
505        fs::write(cache_root.join("retries.json"), "[]").unwrap();
506        fs::write(data_root.join("synced_index.json"), "{\"files\":{}}").unwrap();
507        fs::write(cache_root.join("mimick.log"), "hello log").unwrap();
508
509        let config = Config {
510            data: ConfigData::default(),
511            config_file: config_path,
512        };
513        let state = AppState::default();
514
515        let bundle_dir =
516            export_bundle_with_paths(&dest_root, &state, &config, &cache_root, &data_root).unwrap();
517        assert!(bundle_dir.join("summary.txt").exists());
518        assert!(bundle_dir.join("privacy-note.txt").exists());
519        assert!(bundle_dir.join("config.redacted.json").exists());
520        assert!(bundle_dir.join("status.redacted.json").exists());
521        assert!(bundle_dir.join("retries.redacted.json").exists());
522        assert!(bundle_dir.join("synced_index.redacted.json").exists());
523        assert!(!bundle_dir.join("config.json").exists());
524        assert!(!bundle_dir.join("status.json").exists());
525        assert!(!bundle_dir.join("retries.json").exists());
526        assert!(!bundle_dir.join("synced_index.json").exists());
527        assert!(!bundle_dir.join("mimick.log").exists());
528
529        let config_export = fs::read_to_string(bundle_dir.join("config.redacted.json")).unwrap();
530        assert!(config_export.contains("\"watch_path_count\": 0"));
531        assert!(!config_export.contains("http://localhost"));
532
533        let retry_export = fs::read_to_string(bundle_dir.join("retries.redacted.json")).unwrap();
534        assert!(retry_export.contains("\"total_retry_items\": 0"));
535    }
536}