//! JSON sync report generation. //! //! Writes a structured JSON summary after each sync cycle for machine consumption //! (monitoring tools, Home Assistant, webhooks). In watch mode the file is //! overwritten every cycle — it's the current cycle's state, not a history log. //! //! # Schema v3 //! //! v3 adds metadata-capture revision and repair progress to `stats`. use std::path::{Path, PathBuf}; use serde::Serialize; use crate::download::SyncStats; use crate::fs_util::atomic_install; use crate::state::AssetRecord; /// Cap on `failed_assets` entries so an account with hundreds of thousands of /// failures doesn't blow up the report JSON. The tail count is preserved in /// `failed_assets_truncated`. pub(crate) const FAILED_ASSETS_CAP: usize = 200; /// Structured per-asset failure entry for operators to consume without /// grepping the log. Populated from the state DB after the sync cycle /// completes so it reflects the final `status='failed'` set. #[derive(Debug, Clone, Serialize)] pub(crate) struct FailedAssetEntry { pub id: String, pub version_size: String, pub error_message: Option, } impl FailedAssetEntry { pub(crate) fn from_record(r: &AssetRecord) -> Self { Self { id: r.id.to_string(), version_size: r.version_size.as_str().to_string(), error_message: r.last_error.clone(), } } } /// Top-level JSON report written after each sync cycle. #[derive(Debug, Serialize)] pub(crate) struct SyncReport { /// Schema version for forward compatibility. pub version: &'static str, /// kei binary version. pub kei_version: &'static str, /// ISO 8601 timestamp of when the report was generated. pub timestamp: String, /// Sync outcome: "success", "interrupted", "partial_failure", or "session_expired". pub status: String, /// CLI/config options the sync was invoked with. pub options: RunOptions, /// Accumulated sync statistics. pub stats: SyncStats, /// Up to `FAILED_ASSETS_CAP` structured failure entries (status='failed' /// in the state DB at report time). Empty on clean runs. #[serde(skip_serializing_if = "Vec::is_empty")] pub failed_assets: Vec, /// Number of additional failure rows beyond `failed_assets.len()` that /// were omitted from the report. 0 when all failures fit under the cap. #[serde(skip_serializing_if = "is_zero_usize")] pub failed_assets_truncated: usize, } const fn is_zero_usize(n: &usize) -> bool { *n == 0 } /// User-facing options captured from the resolved Config. No secrets. /// /// `resolution`, `live_photo_mode`, `live_resolution`, `file_match_policy`, and /// `library` are serialized as lowercased `{:?}` of the underlying enum /// (e.g. `VersionSize::Original` → `"original"`). Those enum variant names /// are therefore part of the `sync_report.json` wire format — renaming a /// variant will silently change the emitted JSON. When a variant rename /// is needed, either keep the old lowercase string here explicitly or /// bump the report schema version. #[derive(Debug, Clone, Serialize)] pub(crate) struct RunOptions { pub username: String, pub download_dir: PathBuf, pub folder_structure: String, pub resolution: String, pub live_photo_mode: String, pub live_resolution: String, pub edited: bool, #[serde(skip_serializing_if = "edited_naming_is_default")] pub edited_naming: crate::types::EditedNaming, pub alternative: bool, pub raw_policy: String, pub file_match_policy: String, pub force_resolution: bool, pub albums: Vec, pub library: String, pub media: Vec, pub skip_videos: bool, pub skip_photos: bool, pub set_exif_datetime: bool, pub set_exif_rating: bool, pub set_exif_gps: bool, pub set_exif_description: bool, pub embed_xmp: bool, pub xmp_sidecar: bool, pub threads: u16, pub dry_run: bool, } impl RunOptions { /// Build from the resolved Config. Only includes user-facing settings. pub(crate) fn from_config(config: &crate::config::Config) -> Self { Self { username: config.auth.username.clone(), download_dir: config.download.directory.clone(), folder_structure: config.download.folder_structure.clone(), resolution: format!("{:?}", config.photos.resolution).to_lowercase(), live_photo_mode: format!("{:?}", config.photos.live_photo_mode).to_lowercase(), live_resolution: format!("{:?}", config.photos.live_resolution).to_lowercase(), edited: config.photos.edited, edited_naming: config.photos.edited_naming, alternative: config.photos.alternative, raw_policy: match config.photos.raw_policy { crate::types::RawPolicy::AsIs => "as-is".to_string(), crate::types::RawPolicy::PreferRaw => "prefer-raw".to_string(), crate::types::RawPolicy::PreferJpeg => "prefer-jpeg".to_string(), }, file_match_policy: format!("{:?}", config.photos.file_match_policy).to_lowercase(), force_resolution: config.photos.force_resolution, albums: config.filters.selection.albums.to_raw(), library: config.filters.selection.libraries.to_raw().join(","), media: config.filters.media.to_kinds(), skip_videos: config.filters.skip_videos, skip_photos: config.filters.skip_photos, set_exif_datetime: config.metadata.set_exif_datetime, set_exif_rating: config.metadata.set_exif_rating, set_exif_gps: config.metadata.set_exif_gps, set_exif_description: config.metadata.set_exif_description, #[cfg(feature = "xmp")] embed_xmp: config.metadata.embed_xmp, #[cfg(not(feature = "xmp"))] embed_xmp: false, #[cfg(feature = "xmp")] xmp_sidecar: config.metadata.xmp_sidecar, #[cfg(not(feature = "xmp"))] xmp_sidecar: false, threads: config.download.threads_num, dry_run: config.runtime.dry_run, } } } /// Write a JSON report to the given path atomically. /// /// Serialization runs synchronously (small — typically a few KB), but the /// filesystem write + rename are pushed to the blocking pool so this can /// be called from async contexts without stalling a tokio worker. pub(crate) async fn write_report(path: &Path, report: &SyncReport) -> anyhow::Result<()> { use anyhow::Context; let json = serde_json::to_string_pretty(report)?; let path = path.to_path_buf(); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { let parent = path.parent().unwrap_or(Path::new(".")); let temp_path = parent.join(format!(".kei-report-{}.tmp", std::process::id())); std::fs::write(&temp_path, json.as_bytes()) .with_context(|| format!("Could not write report to {}", temp_path.display()))?; atomic_install(&temp_path, &path) .with_context(|| format!("Could not install report at {}", path.display()))?; tracing::debug!(path = %path.display(), "Wrote JSON report"); Ok(()) }) .await? } fn edited_naming_is_default(value: &crate::types::EditedNaming) -> bool { *value == crate::types::EditedNaming::Suffix } #[cfg(test)] mod tests { use super::*; use crate::download::SkipBreakdown; #[test] fn primary_layout_report_inventory_is_distinct_from_network_counters() { let mut stats = crate::download::SyncStats::default(); let json = serde_json::to_value(&stats).unwrap(); assert!(json.get("primary_layout").is_none()); stats.primary_layout = Some(crate::state::types::PrimaryLayoutSummary { bound_families: 5, preserved_files: 12, pending_operations: 2, held_operations: 1, }); let json = serde_json::to_value(&stats).unwrap(); assert_eq!(json["primary_layout"]["preserved_files"], 12); assert_eq!(json["primary_layout"]["held_operations"], 1); assert_eq!(json["downloaded"], 0); assert_eq!(json["bytes_downloaded"], 0); } #[test] fn report_serialization_roundtrip() { let report = SyncReport { version: "3", kei_version: "0.7.12", timestamp: "2026-04-15T12:00:00Z".to_string(), status: "success".to_string(), options: RunOptions { username: "user@example.com".to_string(), download_dir: PathBuf::from("/photos"), folder_structure: "{:%Y/%m/%d}".to_string(), resolution: "original".to_string(), live_photo_mode: "original".to_string(), live_resolution: "original".to_string(), edited: false, edited_naming: crate::types::EditedNaming::Suffix, alternative: false, raw_policy: "as-is".to_string(), force_resolution: false, file_match_policy: "name-size-dedup".to_string(), albums: vec!["Favorites".to_string()], library: "personal".to_string(), media: crate::config::MediaSelection::all().to_kinds(), skip_videos: false, skip_photos: false, set_exif_datetime: true, set_exif_rating: false, set_exif_gps: false, set_exif_description: false, embed_xmp: false, xmp_sidecar: false, threads: 4, dry_run: false, }, stats: SyncStats { assets_seen: 400, api_total_at_start: Some(405), downloaded: 50, failed: 2, skipped: SkipBreakdown { by_state: 300, on_disk: 30, by_media_type: 10, by_date_range: 5, ..SkipBreakdown::default() }, bytes_downloaded: 1_200_000_000, disk_bytes_written: 1_300_000_000, metadata_capture_revision: Some(1), metadata_capture_refreshed: 25, metadata_capture_failures: 2, metadata_capture_remaining: 75, metadata_capture_unresolved: 19, metadata_capture_deferred: 18, inventory_drop_warnings: 1, inventory_drop_assets: 5, inventory_drop_percent: Some(1.2), inventory_drop_previous_total: Some(410), inventory_drop_current_total: Some(405), inventory_drop_library: Some("PrimarySync".to_string()), elapsed_secs: 263.5, ..SyncStats::default() }, failed_assets: vec![], failed_assets_truncated: 0, }; let json = serde_json::to_string_pretty(&report).expect("serialize"); let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse"); assert_eq!(parsed["version"], "3"); assert_eq!(parsed["status"], "success"); assert_eq!(parsed["stats"]["downloaded"], 50); assert_eq!(parsed["stats"]["api_total_at_start"], 405); assert_eq!(parsed["stats"]["metadata_capture_revision"], 1); assert_eq!(parsed["stats"]["metadata_capture_refreshed"], 25); assert_eq!(parsed["stats"]["metadata_capture_failures"], 2); assert_eq!(parsed["stats"]["metadata_capture_remaining"], 75); assert_eq!(parsed["stats"]["metadata_capture_unresolved"], 19); assert_eq!(parsed["stats"]["metadata_capture_deferred"], 18); assert_eq!(parsed["stats"]["inventory_drop_warnings"], 1); assert_eq!(parsed["stats"]["inventory_drop_previous_total"], 410); assert_eq!(parsed["stats"]["inventory_drop_current_total"], 405); assert_eq!(parsed["stats"]["inventory_drop_library"], "PrimarySync"); assert_eq!(parsed["stats"]["skipped"]["by_state"], 300); assert_eq!(parsed["options"]["username"], "user@example.com"); assert!( parsed["options"]["set_exif_datetime"] .as_bool() .unwrap_or(false) ); } #[test] fn run_options_from_config_uses_schema_v2_option_names() { let download_dir = tempfile::tempdir().expect("download dir"); let globals = crate::config::GlobalArgs { username: Some("report@example.com".to_string()), domain: None, data_dir: None, }; let sync = crate::cli::SyncArgs { config_overrides: crate::config::SyncConfigOverrides { download_dir: Some(download_dir.path().display().to_string()), threads: Some(6), ..Default::default() }, ..crate::cli::SyncArgs::default() }; let config = crate::config::Config::build( &globals, &crate::cli::PasswordArgs::default(), sync, None, ) .expect("build config"); let options = RunOptions::from_config(&config); assert_eq!(options.download_dir, download_dir.path()); assert_eq!(options.threads, 6); let json = serde_json::to_value(&options).expect("serialize options"); assert_eq!( json["download_dir"], download_dir.path().display().to_string() ); assert_eq!(json["threads"], 6); assert!( json.get("directory").is_none(), "schema v3 must not emit the legacy options.directory key" ); assert!( json.get("threads_num").is_none(), "schema v3 must not emit the legacy options.threads_num key" ); } #[test] fn run_options_from_config_reports_media_selection() { let download_dir = tempfile::tempdir().expect("download dir"); let globals = crate::config::GlobalArgs { username: Some("report@example.com".to_string()), domain: None, data_dir: None, }; let sync = crate::cli::SyncArgs { config_overrides: crate::config::SyncConfigOverrides { download_dir: Some(download_dir.path().display().to_string()), ..Default::default() }, ..crate::cli::SyncArgs::default() }; let toml: crate::config::TomlConfig = toml::from_str("[filters]\nmedia = [\"photos\", \"videos\"]\n") .expect("parse media config"); let config = crate::config::Config::build( &globals, &crate::cli::PasswordArgs::default(), sync, Some(&toml), ) .expect("build config"); let options = RunOptions::from_config(&config); assert_eq!( options.media, vec![ crate::config::MediaKind::Photos, crate::config::MediaKind::Videos, ], ); assert!(!options.skip_videos); assert!(!options.skip_photos); let json = serde_json::to_value(&options).expect("serialize options"); assert_eq!(json["media"], serde_json::json!(["photos", "videos"])); } #[test] fn failed_assets_are_omitted_when_empty() { // serde(skip_serializing_if = "Vec::is_empty") on failed_assets // and is_zero_usize on failed_assets_truncated must keep clean-run // reports free of both fields. let report = SyncReport { version: "3", kei_version: "test", timestamp: "2026-04-15T12:00:00Z".to_string(), status: "success".to_string(), options: RunOptions { username: "u".to_string(), download_dir: PathBuf::from("/x"), folder_structure: String::new(), resolution: String::new(), live_photo_mode: String::new(), live_resolution: String::new(), edited: false, edited_naming: crate::types::EditedNaming::Suffix, alternative: false, raw_policy: String::new(), force_resolution: false, file_match_policy: String::new(), albums: vec![], library: String::new(), media: crate::config::MediaSelection::all().to_kinds(), skip_videos: false, skip_photos: false, set_exif_datetime: false, set_exif_rating: false, set_exif_gps: false, set_exif_description: false, embed_xmp: false, xmp_sidecar: false, threads: 1, dry_run: false, }, stats: SyncStats::default(), failed_assets: vec![], failed_assets_truncated: 0, }; let json = serde_json::to_string(&report).unwrap(); assert!( !json.contains("failed_assets"), "empty failed_assets should not appear in JSON: {json}" ); assert!( !json.contains("failed_assets_truncated"), "zero truncated counter should not appear: {json}" ); } #[test] fn failed_assets_serialize_when_present() { let entry = FailedAssetEntry { id: "ASSET_1".to_string(), version_size: "original".to_string(), error_message: Some("HTTP 429".to_string()), }; let report = SyncReport { version: "3", kei_version: "test", timestamp: "2026-04-15T12:00:00Z".to_string(), status: "partial_failure".to_string(), options: RunOptions { username: "u".to_string(), download_dir: PathBuf::from("/x"), folder_structure: String::new(), resolution: String::new(), live_photo_mode: String::new(), live_resolution: String::new(), edited: false, edited_naming: crate::types::EditedNaming::Suffix, alternative: false, raw_policy: String::new(), force_resolution: false, file_match_policy: String::new(), albums: vec![], library: String::new(), media: crate::config::MediaSelection::all().to_kinds(), skip_videos: false, skip_photos: false, set_exif_datetime: false, set_exif_rating: false, set_exif_gps: false, set_exif_description: false, embed_xmp: false, xmp_sidecar: false, threads: 1, dry_run: false, }, stats: SyncStats { failed: 1, ..SyncStats::default() }, failed_assets: vec![entry], failed_assets_truncated: 0, }; let parsed: serde_json::Value = serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap(); assert_eq!(parsed["failed_assets"][0]["id"], "ASSET_1"); assert_eq!(parsed["failed_assets"][0]["version_size"], "original"); assert_eq!(parsed["failed_assets"][0]["error_message"], "HTTP 429"); assert!(parsed["failed_assets_truncated"].is_null()); } #[test] fn failed_assets_truncated_emitted_when_nonzero() { let report = SyncReport { version: "3", kei_version: "test", timestamp: "2026-04-15T12:00:00Z".to_string(), status: "partial_failure".to_string(), options: RunOptions { username: "u".to_string(), download_dir: PathBuf::from("/x"), folder_structure: String::new(), resolution: String::new(), live_photo_mode: String::new(), live_resolution: String::new(), edited: false, edited_naming: crate::types::EditedNaming::Suffix, alternative: false, raw_policy: String::new(), force_resolution: false, file_match_policy: String::new(), albums: vec![], library: String::new(), media: crate::config::MediaSelection::all().to_kinds(), skip_videos: false, skip_photos: false, set_exif_datetime: false, set_exif_rating: false, set_exif_gps: false, set_exif_description: false, embed_xmp: false, xmp_sidecar: false, threads: 1, dry_run: false, }, stats: SyncStats::default(), failed_assets: vec![FailedAssetEntry { id: "x".to_string(), version_size: "original".to_string(), error_message: None, }], failed_assets_truncated: 847, }; let parsed: serde_json::Value = serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap(); assert_eq!(parsed["failed_assets_truncated"], 847); } #[tokio::test] async fn write_report_creates_valid_json_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("report.json"); let report = SyncReport { version: "3", kei_version: "0.7.12", timestamp: "2026-04-15T12:00:00Z".to_string(), status: "success".to_string(), options: RunOptions { username: "test@example.com".to_string(), download_dir: PathBuf::from("/tmp/photos"), folder_structure: "{:%Y/%m/%d}".to_string(), resolution: "original".to_string(), live_photo_mode: "original".to_string(), live_resolution: "original".to_string(), edited: false, edited_naming: crate::types::EditedNaming::Suffix, alternative: false, raw_policy: "as-is".to_string(), force_resolution: false, file_match_policy: "name-size-dedup".to_string(), albums: vec![], library: "personal".to_string(), media: crate::config::MediaSelection::all().to_kinds(), skip_videos: false, skip_photos: false, set_exif_datetime: false, set_exif_rating: false, set_exif_gps: false, set_exif_description: false, embed_xmp: false, xmp_sidecar: false, threads: 3, dry_run: false, }, stats: SyncStats::default(), failed_assets: vec![], failed_assets_truncated: 0, }; write_report(&path, &report).await.expect("write_report"); let content = std::fs::read_to_string(&path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON"); assert_eq!(parsed["version"], "3"); assert_eq!(parsed["options"]["username"], "test@example.com"); } }