Skip to main content

cha_core/
cache.rs

1use crate::Finding;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6/// Cache entry: content hash → findings.
7#[derive(Debug, Serialize, Deserialize)]
8struct CacheEntry {
9    content_hash: u64,
10    findings: Vec<Finding>,
11}
12
13/// On-disk analysis cache stored at `.cha/cache/analysis.json`.
14#[derive(Debug, Serialize, Deserialize, Default)]
15struct CacheData {
16    /// Hash of all config files + plugin versions; if changed, entire cache is invalid.
17    env_hash: u64,
18    entries: HashMap<String, CacheEntry>,
19}
20
21/// Incremental analysis cache.
22///
23/// Key = file path (relative), value = (content_hash, findings).
24/// The entire cache is invalidated when `env_hash` changes
25/// (config edits, plugin additions/removals).
26pub struct AnalysisCache {
27    path: PathBuf,
28    data: CacheData,
29    dirty: bool,
30}
31
32fn hash_all_configs(dir: &Path, h: &mut impl std::hash::Hasher) {
33    use std::hash::Hash;
34    let cfg = dir.join(".cha.toml");
35    if let Ok(content) = std::fs::read_to_string(&cfg) {
36        content.hash(h);
37    }
38    let Ok(entries) = std::fs::read_dir(dir) else {
39        return;
40    };
41    for entry in entries.flatten() {
42        let path = entry.path();
43        if path.is_dir() {
44            let name = entry.file_name();
45            let s = name.to_string_lossy();
46            if !s.starts_with('.') && !matches!(s.as_ref(), "target" | "node_modules" | "dist") {
47                hash_all_configs(&path, h);
48            }
49        }
50    }
51}
52
53impl AnalysisCache {
54    /// Open (or create) a cache for the given project root.
55    pub fn open(project_root: &Path, env_hash: u64) -> Self {
56        let path = project_root.join(".cha/cache/analysis.json");
57        let data = std::fs::read_to_string(&path)
58            .ok()
59            .and_then(|s| serde_json::from_str::<CacheData>(&s).ok())
60            .unwrap_or_default();
61        // Invalidate if environment changed.
62        let data = if data.env_hash != env_hash {
63            CacheData {
64                env_hash,
65                entries: HashMap::new(),
66            }
67        } else {
68            data
69        };
70        Self {
71            path,
72            data,
73            dirty: false,
74        }
75    }
76
77    /// Look up cached findings for a file. Returns `Some` if content hash matches.
78    pub fn get(&self, rel_path: &str, content_hash: u64) -> Option<&[Finding]> {
79        let entry = self.data.entries.get(rel_path)?;
80        if entry.content_hash == content_hash {
81            Some(&entry.findings)
82        } else {
83            None
84        }
85    }
86
87    /// Store findings for a file.
88    pub fn put(&mut self, rel_path: String, content_hash: u64, findings: Vec<Finding>) {
89        self.data.entries.insert(
90            rel_path,
91            CacheEntry {
92                content_hash,
93                findings,
94            },
95        );
96        self.dirty = true;
97    }
98
99    /// Flush to disk if anything changed.
100    pub fn flush(&self) {
101        if !self.dirty {
102            return;
103        }
104        if let Some(dir) = self.path.parent() {
105            let _ = std::fs::create_dir_all(dir);
106        }
107        if let Ok(json) = serde_json::to_string(&self.data) {
108            let _ = std::fs::write(&self.path, json);
109        }
110    }
111
112    /// Compute a content hash using the same fast hasher.
113    pub fn hash_content(content: &str) -> u64 {
114        use std::hash::{Hash, Hasher};
115        let mut h = std::collections::hash_map::DefaultHasher::new();
116        content.hash(&mut h);
117        h.finish()
118    }
119
120    /// Compute an environment hash from config content + plugin file mtimes.
121    pub fn env_hash(project_root: &Path, plugin_dirs: &[PathBuf]) -> u64 {
122        use std::hash::{Hash, Hasher};
123        let mut h = std::collections::hash_map::DefaultHasher::new();
124        // Hash all .cha.toml files (root + subdirectories)
125        hash_all_configs(project_root, &mut h);
126        for dir in plugin_dirs {
127            if let Ok(entries) = std::fs::read_dir(dir) {
128                for entry in entries.flatten() {
129                    if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
130                        mtime.hash(&mut h);
131                    }
132                    entry.file_name().hash(&mut h);
133                }
134            }
135        }
136        h.finish()
137    }
138}