Ctrl K
ComunidadGuías…

src/report.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! 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<String>,
}

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<FailedAssetEntry>,
    /// 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<String>,
    pub library: String,
    pub media: Vec<crate::config::MediaKind>,
    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");
    }
}