Ctrl K
ComunidadGuías…

src/health.rs

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

Archivo original ↗Preguntar a KiBot
use std::path::Path;

use chrono::{DateTime, Utc};
use serde::Serialize;

#[derive(Debug, Serialize)]
pub(crate) struct HealthStatus {
    pub(crate) last_sync_at: Option<DateTime<Utc>>,
    pub(crate) last_success_at: Option<DateTime<Utc>>,
    pub(crate) consecutive_failures: u32,
    pub(crate) last_error: Option<String>,
    pub(crate) unattributed_legacy_assets: u64,
    pub(crate) unattributed_legacy_pending: u64,
}

impl HealthStatus {
    pub(crate) const fn new() -> Self {
        Self {
            last_sync_at: None,
            last_success_at: None,
            consecutive_failures: 0,
            last_error: None,
            unattributed_legacy_assets: 0,
            unattributed_legacy_pending: 0,
        }
    }

    pub(crate) fn record_success(&mut self) {
        let now = Utc::now();
        self.last_sync_at = Some(now);
        self.last_success_at = Some(now);
        self.consecutive_failures = 0;
        self.last_error = None;
    }

    pub(crate) fn record_failure(&mut self, error: &str) {
        self.last_sync_at = Some(Utc::now());
        self.consecutive_failures += 1;
        self.last_error = Some(error.to_string());
    }

    /// Write health status to `health.json` in the given directory.
    /// Errors are logged but never propagated — health reporting must not
    /// interfere with the sync loop.
    pub(crate) fn write(&self, dir: &Path) {
        let path = dir.join("health.json");
        match serde_json::to_string_pretty(self) {
            Ok(json) => {
                if let Err(e) = std::fs::write(&path, json) {
                    tracing::warn!(error = %e, path = %path.display(), "Failed to write health file");
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "Failed to serialize health status");
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_has_no_timestamps() {
        let h = HealthStatus::new();
        assert!(h.last_sync_at.is_none());
        assert!(h.last_success_at.is_none());
        assert_eq!(h.consecutive_failures, 0);
        assert!(h.last_error.is_none());
    }

    #[test]
    fn record_success_sets_timestamps_and_resets_failures() {
        let mut h = HealthStatus::new();
        h.record_failure("boom");
        h.record_failure("boom again");
        h.record_success();

        assert!(h.last_sync_at.is_some());
        assert!(h.last_success_at.is_some());
        assert_eq!(h.consecutive_failures, 0);
        assert!(h.last_error.is_none());
    }

    #[test]
    fn record_failure_increments_and_preserves_last_success() {
        let mut h = HealthStatus::new();
        h.record_success();
        let success_time = h.last_success_at;

        h.record_failure("err1");
        assert_eq!(h.consecutive_failures, 1);
        assert_eq!(h.last_error.as_deref(), Some("err1"));
        assert_eq!(h.last_success_at, success_time);

        h.record_failure("err2");
        assert_eq!(h.consecutive_failures, 2);
        assert_eq!(h.last_error.as_deref(), Some("err2"));
    }

    #[test]
    fn write_creates_valid_json() {
        let dir = tempfile::tempdir().unwrap();

        let mut h = HealthStatus::new();
        h.record_success();
        h.write(dir.path());

        let contents = std::fs::read_to_string(dir.path().join("health.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
        assert!(parsed["last_sync_at"].is_string());
        assert!(parsed["last_success_at"].is_string());
        assert_eq!(parsed["consecutive_failures"], 0);
        assert!(parsed["last_error"].is_null());
    }

    #[test]
    fn write_nonexistent_dir_does_not_panic() {
        let dir = tempfile::tempdir().unwrap();
        let h = HealthStatus::new();
        h.write(&dir.path().join("nonexistent_subdir"));
    }

    #[test]
    fn failure_with_special_chars_produces_valid_json() {
        let dir = tempfile::tempdir().unwrap();
        let mut h = HealthStatus::new();
        h.record_failure("error with \"quotes\" and\nnewlines");
        h.write(dir.path());

        let contents = std::fs::read_to_string(dir.path().join("health.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
        assert_eq!(
            parsed["last_error"].as_str().unwrap(),
            "error with \"quotes\" and\nnewlines"
        );
    }

    #[test]
    fn multiple_failures_increment_correctly() {
        let mut h = HealthStatus::new();
        for _ in 0..5 {
            h.record_failure("err");
        }
        assert_eq!(h.consecutive_failures, 5);
    }

    #[test]
    fn success_after_failures_resets_count() {
        let mut h = HealthStatus::new();
        h.record_failure("e1");
        h.record_failure("e2");
        h.record_failure("e3");
        h.record_success();

        assert_eq!(h.consecutive_failures, 0);
        assert!(h.last_error.is_none());
    }

    #[test]
    fn health_json_timestamps_are_iso8601() {
        let dir = tempfile::tempdir().unwrap();
        let mut h = HealthStatus::new();
        h.record_success();
        h.write(dir.path());

        let contents = std::fs::read_to_string(dir.path().join("health.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();

        for key in &["last_sync_at", "last_success_at"] {
            let ts = parsed[key].as_str().unwrap();
            assert!(
                chrono::DateTime::parse_from_rfc3339(ts).is_ok(),
                "{key} timestamp is not ISO 8601: {ts}"
            );
        }
    }
}