quotch 0.5.1

Fast cross-platform CLI for AI coding-agent usage limits
use crate::model::Snapshot;
use std::path::PathBuf;
use std::time::Duration;

pub const TTL: Duration = Duration::from_secs(60);

/// How long a failure marker suppresses re-fetching. Keeps repeated polls
/// (statusline, agent loops) from each paying the full network timeout while
/// the network is known-down.
pub const FAIL_BACKOFF: Duration = Duration::from_secs(30);

pub struct Cache {
    dir: PathBuf,
}

impl Cache {
    pub fn new() -> Cache {
        let dir = match std::env::var("XDG_CACHE_HOME") {
            Ok(v) if !v.is_empty() => PathBuf::from(v).join("quotch"),
            _ => std::env::home_dir()
                .unwrap_or_else(std::env::temp_dir)
                .join(".cache")
                .join("quotch"),
        };
        let _ = std::fs::create_dir_all(&dir);
        Cache { dir }
    }

    fn path_for(&self, provider: &str, account: &str) -> PathBuf {
        self.dir
            .join(format!("{}.{}.json", sanitize(provider), sanitize(account)))
    }

    fn fail_path_for(&self, provider: &str, account: &str) -> PathBuf {
        self.dir
            .join(format!("{}.{}.fail", sanitize(provider), sanitize(account)))
    }

    /// Loads a cached snapshot regardless of age. Caller compares `fetched_at` against TTL.
    /// Returns `None` on any error (missing file, unreadable, corrupt JSON, etc).
    pub fn load(&self, provider: &str, account: &str) -> Option<Snapshot> {
        let bytes = std::fs::read(self.path_for(provider, account)).ok()?;
        serde_json::from_slice(&bytes).ok()
    }

    /// Writes the snapshot atomically. Ignores all errors — cache failures must never
    /// break the CLI.
    pub fn store(&self, snap: &Snapshot) {
        let Ok(bytes) = serde_json::to_vec(snap) else {
            return;
        };
        let path = self.path_for(&snap.provider, &snap.account);
        let tmp = PathBuf::from(format!("{}.tmp", path.display()));
        if std::fs::write(&tmp, bytes).is_err() {
            return;
        }
        let _ = std::fs::rename(&tmp, &path);
    }

    /// Records a fetch failure by touching an empty marker file. Ignores errors —
    /// a failed marker write just means no backoff next run, not a crash.
    pub fn mark_failure(&self, provider: &str, account: &str) {
        let _ = std::fs::write(self.fail_path_for(provider, account), []);
    }

    /// Clears a failure marker, e.g. after a fetch succeeds again.
    pub fn clear_failure(&self, provider: &str, account: &str) {
        let _ = std::fs::remove_file(self.fail_path_for(provider, account));
    }

    /// True if a failure was marked within the last `FAIL_BACKOFF`. False on any
    /// error (no marker, unreadable metadata, clock skew).
    pub fn recent_failure(&self, provider: &str, account: &str) -> bool {
        std::fs::metadata(self.fail_path_for(provider, account))
            .and_then(|m| m.modified())
            .is_ok_and(|modified| modified.elapsed().is_ok_and(|age| age < FAIL_BACKOFF))
    }
}

impl Default for Cache {
    fn default() -> Cache {
        Cache::new()
    }
}

fn sanitize(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
                c
            } else {
                '-'
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Status, Unit, Window, WindowKind};
    use chrono::Utc;

    fn temp_cache(name: &str) -> Cache {
        let dir =
            std::env::temp_dir().join(format!("quotch-cache-test-{}-{}", name, std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        Cache { dir }
    }

    fn sample_snapshot() -> Snapshot {
        Snapshot {
            provider: "claude".to_string(),
            account: "default".to_string(),
            label: None,
            plan: Some("pro".to_string()),
            windows: vec![Window {
                key: "5h".to_string(),
                kind: WindowKind::Rolling,
                unit: Unit::Percent,
                used_pct: 42.0,
                used: Some(42.0),
                limit: Some(100.0),
                unlimited: false,
                resets_at: None,
            }],
            fetched_at: Utc::now(),
            status: Status::Ok,
            error: None,
            raw: Some(serde_json::json!({"foo": "bar"})),
        }
    }

    #[test]
    fn store_then_load_roundtrips() {
        let cache = temp_cache("roundtrip");
        let snap = sample_snapshot();

        cache.store(&snap);
        let loaded = cache
            .load(&snap.provider, &snap.account)
            .expect("expected a cached snapshot");

        assert_eq!(loaded.provider, snap.provider);
        assert_eq!(loaded.account, snap.account);
        assert_eq!(loaded.windows.len(), snap.windows.len());
        assert_eq!(loaded.status, snap.status);

        let _ = std::fs::remove_dir_all(&cache.dir);
    }

    #[test]
    fn load_missing_entry_returns_none() {
        let cache = temp_cache("missing");

        assert!(cache.load("nope", "nobody").is_none());

        let _ = std::fs::remove_dir_all(&cache.dir);
    }

    #[test]
    fn load_corrupt_file_returns_none() {
        let cache = temp_cache("corrupt");
        let path = cache.path_for("claude", "default");
        std::fs::write(&path, b"not valid json {{{").expect("write garbage");

        assert!(cache.load("claude", "default").is_none());

        let _ = std::fs::remove_dir_all(&cache.dir);
    }

    #[test]
    fn failure_marker_roundtrips() {
        let cache = temp_cache("failure-marker");

        assert!(!cache.recent_failure("claude", "default"));

        cache.mark_failure("claude", "default");
        assert!(cache.recent_failure("claude", "default"));

        cache.clear_failure("claude", "default");
        assert!(!cache.recent_failure("claude", "default"));

        let _ = std::fs::remove_dir_all(&cache.dir);
    }
}