1use 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
17pub 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
30fn 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
72fn 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
180fn cache_path(cache_root: &Path, name: &str) -> PathBuf {
182 cache_root.join(name)
183}
184
185#[derive(Serialize)]
187struct RedactedConfigExport {
188 internal_url_enabled: bool,
190 external_url_enabled: bool,
192 watch_path_count: usize,
194 watch_paths_with_custom_album: usize,
196 watch_paths_with_rules: usize,
198 run_on_startup: bool,
200 pause_on_metered_network: bool,
202 pause_on_battery_power: bool,
204 background_sync_enabled: bool,
206 notifications_enabled: bool,
208 startup_catchup_mode: String,
210 upload_concurrency: u8,
212 quiet_hours_start: Option<u8>,
214 quiet_hours_end: Option<u8>,
216}
217
218#[derive(Serialize)]
220struct RedactedQueueEvent {
221 path_hint: String,
223 status: String,
225 detail: Option<String>,
227 attempts: u32,
229 timestamp: f64,
231}
232
233#[derive(Serialize)]
235struct RedactedStateExport {
236 status: String,
238 paused: bool,
240 pause_reason: Option<String>,
242 watched_folder_count: usize,
244 active_server_route: Option<String>,
246 queue_size: usize,
248 total_queued: usize,
250 processed_count: usize,
252 failed_count: usize,
254 progress: u8,
256 last_successful_sync_at: Option<f64>,
258 current_file: Option<String>,
260 last_completed_file: Option<String>,
262 last_error: Option<String>,
264 last_error_guidance: Option<String>,
266 diagnostics_exports: usize,
268 recent_events: Vec<RedactedQueueEvent>,
270}
271
272#[derive(Serialize)]
274struct RedactedRetryExport {
275 total_retry_items: usize,
277 reassociate_only_items: usize,
279 album_targeted_items: usize,
281}
282
283#[derive(Serialize)]
285struct RedactedSyncIndexExport {
286 total_entries: usize,
288 album_named_entries: usize,
290 album_id_entries: usize,
292}
293
294fn 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
332fn 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
365fn 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
384fn 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
428fn 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
434fn 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}