cleanlib-cli 0.1.1

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! Sled-backed 7d-TTL verdict cache (cycle-7 Cli7).
//!
//! Schema (sled default tree):
//!   key:   `<eco>/<pkg>/<ver>` UTF-8 bytes
//!   value: bincode-serialized `CachedEntry { envelope: Verdict, cached_at_unix: i64 }`
//!
//! TTL eviction is **lazy** (checked on read, not via a background sweep) —
//! keeps CLI cold-start fast (~5ms target) per cycle-7 entry §2.6 perf budget.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use cleanlib_client::types::Verdict;
use serde::{Deserialize, Serialize};

/// 7-day TTL per dispatch §2.6 + cycle-7 entry §2.6 "(cached: 2026-05-21)"
/// annotation; entries older than this are treated as cache-miss.
pub const DEFAULT_TTL_DAYS: i64 = 7;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedEntry {
    pub envelope: Verdict,
    /// Unix epoch (seconds, UTC) at which the live response was cached.
    /// Used to compute TTL freshness; sister of the `(cached: YYYY-MM-DD)`
    /// annotation the CLI emits on offline fallback.
    pub cached_at_unix: i64,
}

impl CachedEntry {
    pub fn cached_at(&self) -> Option<DateTime<Utc>> {
        DateTime::from_timestamp(self.cached_at_unix, 0)
    }
}

/// Default on-disk cache root — `$HOME/.cleanlib/cache.sled/`. Sister of
/// dispatch §2.6 path. Returns `None` when the OS has no discoverable home.
pub fn default_cache_dir() -> Option<PathBuf> {
    dirs::home_dir().map(|h| h.join(".cleanlib").join("cache.sled"))
}

/// Stable cache key for a `(ecosystem, package, version)` triple.
pub fn cache_key(ecosystem: &str, package: &str, version: &str) -> String {
    format!("{}/{}/{}", ecosystem, package, version)
}

/// Sled-backed verdict cache. Cheap to clone (sled handle is Arc internally).
pub struct PersistentCache {
    db: sled::Db,
    ttl_days: i64,
}

#[allow(dead_code)]
impl PersistentCache {
    /// Open (or create) the cache at the given path. Always uses `DEFAULT_TTL_DAYS`.
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        let db = sled::open(path.as_ref()).with_context(|| {
            format!(
                "failed to open verdict cache at {}",
                path.as_ref().display()
            )
        })?;
        Ok(Self {
            db,
            ttl_days: DEFAULT_TTL_DAYS,
        })
    }

    /// Open with explicit TTL (primarily for tests).
    pub fn open_with_ttl<P: AsRef<Path>>(path: P, ttl_days: i64) -> Result<Self> {
        let mut c = Self::open(path)?;
        c.ttl_days = ttl_days;
        Ok(c)
    }

    /// Get a verdict from the cache iff present AND within TTL. Returns
    /// `Ok(None)` for both cache-miss and TTL-expired (eviction is lazy +
    /// transparent to the caller).
    pub fn get(&self, ecosystem: &str, package: &str, version: &str) -> Result<Option<CachedEntry>> {
        let key = cache_key(ecosystem, package, version);
        let raw = match self.db.get(&key)? {
            Some(v) => v,
            None => return Ok(None),
        };
        let entry: CachedEntry = bincode::deserialize(&raw)
            .context("verdict cache entry deserialize failed; entry skipped")?;
        // TTL check
        let now = Utc::now().timestamp();
        let age_secs = now.saturating_sub(entry.cached_at_unix);
        let ttl_secs = self.ttl_days * 24 * 3600;
        if age_secs > ttl_secs {
            // Lazy eviction
            let _ = self.db.remove(&key);
            return Ok(None);
        }
        Ok(Some(entry))
    }

    /// Store/replace a verdict entry. Sets `cached_at_unix = now` automatically.
    pub fn put(&self, ecosystem: &str, package: &str, version: &str, verdict: Verdict) -> Result<()> {
        let entry = CachedEntry {
            envelope: verdict,
            cached_at_unix: Utc::now().timestamp(),
        };
        let raw = bincode::serialize(&entry).context("verdict cache entry serialize failed")?;
        let key = cache_key(ecosystem, package, version);
        self.db
            .insert(key.as_bytes(), raw)
            .context("verdict cache insert failed")?;
        Ok(())
    }

    /// Force a flush to disk (sled flushes lazily by default). Primarily for
    /// tests + the explicit `cleanlib status --flush-cache` future surface.
    pub fn flush(&self) -> Result<()> {
        self.db.flush().context("verdict cache flush failed")?;
        Ok(())
    }

    /// Number of entries currently in the cache (TTL-expired entries that
    /// haven't been lazily evicted yet are still counted).
    pub fn len(&self) -> usize {
        self.db.len()
    }

    pub fn is_empty(&self) -> bool {
        self.db.is_empty()
    }
}

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

    fn tmp_dir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("cleanlib-cache-test-{}", name));
        let _ = std::fs::remove_dir_all(&dir);
        dir
    }

    fn mk_verdict(id: &str) -> Verdict {
        Verdict {
            verdict_id: id.to_string(),
            verdict: "ALLOWED_NO_FINDINGS".to_string(),
            source: "ALLOWED_NO_FINDINGS".to_string(),
            ..Verdict::default()
        }
    }

    #[test]
    fn cache_key_is_stable() {
        assert_eq!(cache_key("npm", "cors", "2.8.5"), "npm/cors/2.8.5");
        assert_eq!(
            cache_key("npm", "@scope/pkg", "1.0.0"),
            "npm/@scope/pkg/1.0.0"
        );
    }

    #[test]
    fn miss_returns_none() {
        let dir = tmp_dir("miss");
        let cache = PersistentCache::open(&dir).unwrap();
        assert!(cache.get("npm", "missing", "1.0.0").unwrap().is_none());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn put_then_get_round_trips() {
        let dir = tmp_dir("rt");
        let cache = PersistentCache::open(&dir).unwrap();
        cache.put("npm", "cors", "2.8.5", mk_verdict("v1")).unwrap();
        let entry = cache.get("npm", "cors", "2.8.5").unwrap().unwrap();
        assert_eq!(entry.envelope.verdict_id, "v1");
        assert_eq!(entry.envelope.verdict, "ALLOWED_NO_FINDINGS");
        assert!(entry.cached_at().is_some());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn ttl_expired_evicted_lazily() {
        let dir = tmp_dir("ttl");
        // TTL = 0 days; any entry whose age > 0 seconds is expired.
        let cache = PersistentCache::open_with_ttl(&dir, 0).unwrap();
        cache.put("npm", "stale", "1.0.0", mk_verdict("vstale")).unwrap();
        // Sleep 2s so age > 0 secs (TTL boundary).
        std::thread::sleep(std::time::Duration::from_secs(2));
        let entry = cache.get("npm", "stale", "1.0.0").unwrap();
        assert!(entry.is_none());
        // The lazy eviction should have removed the row.
        assert_eq!(cache.len(), 0);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn put_replaces_existing_entry() {
        let dir = tmp_dir("rep");
        let cache = PersistentCache::open(&dir).unwrap();
        cache.put("npm", "cors", "2.8.5", mk_verdict("v1")).unwrap();
        cache.put("npm", "cors", "2.8.5", mk_verdict("v2")).unwrap();
        let entry = cache.get("npm", "cors", "2.8.5").unwrap().unwrap();
        assert_eq!(entry.envelope.verdict_id, "v2");
        let _ = std::fs::remove_dir_all(&dir);
    }
}