apexe 0.3.0

Outside-In CLI-to-Agent Bridge
use std::path::PathBuf;

use crate::models::ScannedCLITool;

/// Filesystem cache for scan results.
pub struct ScanCache {
    cache_dir: PathBuf,
}

impl ScanCache {
    pub fn new(cache_dir: PathBuf) -> Self {
        Self { cache_dir }
    }

    /// Retrieve cached scan result. Returns None on cache miss or corruption.
    pub fn get(&self, tool_name: &str, tool_version: Option<&str>) -> Option<ScannedCLITool> {
        let key = format!(
            "{}_{}.scan.json",
            tool_name,
            tool_version.unwrap_or("unknown")
        );
        let path = self.cache_dir.join(&key);

        let contents = std::fs::read_to_string(&path).ok()?;
        serde_json::from_str(&contents).ok()
    }

    /// Store scan result in cache.
    pub fn put(&self, tool: &ScannedCLITool) -> anyhow::Result<()> {
        std::fs::create_dir_all(&self.cache_dir)?;
        let key = format!(
            "{}_{}.scan.json",
            tool.name,
            tool.version.as_deref().unwrap_or("unknown")
        );
        let path = self.cache_dir.join(&key);
        let json = serde_json::to_string_pretty(tool)?;
        std::fs::write(&path, json)?;
        Ok(())
    }

    /// Remove all cached results for a given tool name.
    pub fn invalidate(&self, tool_name: &str) {
        if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
            for entry in entries.flatten() {
                let name = entry.file_name();
                let name_str = name.to_string_lossy();
                // Keys are "{name}_{version}.scan.json". Compare the exact name
                // (stem minus the trailing "_{version}"), not a bare prefix —
                // otherwise invalidate("foo") would also delete a sibling tool
                // literally named "foo_bar" (foo_bar_2.0.scan.json).
                let Some(stem) = name_str.strip_suffix(".scan.json") else {
                    continue;
                };
                if let Some((cached_name, _version)) = stem.rsplit_once('_') {
                    if cached_name == tool_name {
                        let _ = std::fs::remove_file(entry.path());
                    }
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::StructuredOutputInfo;
    use tempfile::TempDir;

    fn make_tool(name: &str, version: Option<&str>) -> ScannedCLITool {
        ScannedCLITool {
            name: name.into(),
            binary_path: format!("/usr/bin/{name}"),
            version: version.map(|v| v.to_string()),
            subcommands: vec![],
            global_flags: vec![],
            structured_output: StructuredOutputInfo::default(),
            scan_tier: 1,
            warnings: vec![],
        }
    }

    // T33: store and retrieve
    #[test]
    fn test_cache_put_and_get() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        let tool = make_tool("git", Some("2.43.0"));
        cache.put(&tool).unwrap();

        let cached = cache.get("git", Some("2.43.0"));
        assert!(cached.is_some());
        let cached = cached.unwrap();
        assert_eq!(cached.name, "git");
        assert_eq!(cached.version, Some("2.43.0".into()));
    }

    #[test]
    fn test_cache_miss_wrong_version() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        let tool = make_tool("git", Some("2.43.0"));
        cache.put(&tool).unwrap();

        let cached = cache.get("git", Some("2.44.0"));
        assert!(cached.is_none());
    }

    #[test]
    fn test_cache_miss_empty() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        let cached = cache.get("git", Some("2.43.0"));
        assert!(cached.is_none());
    }

    #[test]
    fn test_cache_unknown_version() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        let tool = make_tool("mytool", None);
        cache.put(&tool).unwrap();

        let cached = cache.get("mytool", None);
        assert!(cached.is_some());
    }

    // T34: invalidation
    #[test]
    fn test_cache_invalidate() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        let tool1 = make_tool("git", Some("2.43.0"));
        let tool2 = make_tool("git", Some("2.44.0"));
        cache.put(&tool1).unwrap();
        cache.put(&tool2).unwrap();

        // Both should be cached
        assert!(cache.get("git", Some("2.43.0")).is_some());
        assert!(cache.get("git", Some("2.44.0")).is_some());

        // Invalidate
        cache.invalidate("git");

        // Both should be gone
        assert!(cache.get("git", Some("2.43.0")).is_none());
        assert!(cache.get("git", Some("2.44.0")).is_none());
    }

    #[test]
    fn test_cache_invalidate_empty_no_error() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());
        // Should not panic or error
        cache.invalidate("nonexistent");
    }

    #[test]
    fn test_cache_invalidate_preserves_other_tools() {
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        let git = make_tool("git", Some("2.43.0"));
        let docker = make_tool("docker", Some("24.0.0"));
        cache.put(&git).unwrap();
        cache.put(&docker).unwrap();

        cache.invalidate("git");

        assert!(cache.get("git", Some("2.43.0")).is_none());
        assert!(cache.get("docker", Some("24.0.0")).is_some());
    }

    #[test]
    fn test_cache_invalidate_does_not_delete_prefix_sibling() {
        // invalidate("foo") must not delete a distinct tool named "foo_bar",
        // whose key "foo_bar_2.0.scan.json" shares the "foo_" prefix.
        let tmp = TempDir::new().unwrap();
        let cache = ScanCache::new(tmp.path().to_path_buf());

        cache.put(&make_tool("foo", Some("1.0"))).unwrap();
        cache.put(&make_tool("foo_bar", Some("2.0"))).unwrap();

        cache.invalidate("foo");

        assert!(
            cache.get("foo", Some("1.0")).is_none(),
            "foo not invalidated"
        );
        assert!(
            cache.get("foo_bar", Some("2.0")).is_some(),
            "sibling foo_bar wrongly deleted by invalidate(foo)"
        );
    }
}