Skip to main content

boundary_core/
cache.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::types::{Component, Dependency};
9
10/// Cache entry for a single file's analysis results.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CachedFileResult {
13    pub hash: String,
14    pub components: Vec<Component>,
15    pub dependencies: Vec<Dependency>,
16}
17
18/// Analysis cache stored in `.boundary/cache.json`.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct AnalysisCache {
21    pub files: HashMap<String, CachedFileResult>,
22}
23
24const CACHE_DIR: &str = ".boundary";
25const CACHE_FILE: &str = "cache.json";
26
27impl AnalysisCache {
28    pub fn new() -> Self {
29        Self {
30            files: HashMap::new(),
31        }
32    }
33
34    /// Load cache from `.boundary/cache.json` relative to project root.
35    pub fn load(project_root: &Path) -> Result<Self> {
36        let cache_path = project_root.join(CACHE_DIR).join(CACHE_FILE);
37        if !cache_path.exists() {
38            return Ok(Self::new());
39        }
40        let content =
41            std::fs::read_to_string(&cache_path).context("failed to read analysis cache")?;
42        let cache: Self =
43            serde_json::from_str(&content).context("failed to parse analysis cache")?;
44        Ok(cache)
45    }
46
47    /// Save cache to `.boundary/cache.json` relative to project root.
48    pub fn save(&self, project_root: &Path) -> Result<()> {
49        let cache_dir = project_root.join(CACHE_DIR);
50        std::fs::create_dir_all(&cache_dir).context("failed to create .boundary directory")?;
51        let cache_path = cache_dir.join(CACHE_FILE);
52        let content =
53            serde_json::to_string_pretty(self).context("failed to serialize analysis cache")?;
54        std::fs::write(&cache_path, content).context("failed to write analysis cache")?;
55        Ok(())
56    }
57
58    /// Check if a file's cached result is stale (content changed).
59    pub fn is_stale(&self, rel_path: &str, content: &str) -> bool {
60        match self.files.get(rel_path) {
61            Some(cached) => cached.hash != compute_hash(content),
62            None => true, // Not in cache = stale
63        }
64    }
65
66    /// Get cached result for a file if it exists and is current.
67    pub fn get(&self, rel_path: &str, content: &str) -> Option<&CachedFileResult> {
68        let cached = self.files.get(rel_path)?;
69        if cached.hash == compute_hash(content) {
70            Some(cached)
71        } else {
72            None
73        }
74    }
75
76    /// Insert or update a file's cache entry.
77    pub fn insert(&mut self, rel_path: String, content: &str, result: CachedFileResult) {
78        let mut entry = result;
79        entry.hash = compute_hash(content);
80        self.files.insert(rel_path, entry);
81    }
82
83    /// Remove entries for files that no longer exist.
84    pub fn prune(&mut self, existing_files: &[String]) {
85        let existing_set: std::collections::HashSet<&str> =
86            existing_files.iter().map(|s| s.as_str()).collect();
87        self.files
88            .retain(|path, _| existing_set.contains(path.as_str()));
89    }
90
91    /// Try to quickly identify changed files using `git diff --name-only`.
92    /// Returns None if not in a git repo or git fails.
93    pub fn git_changed_files(project_root: &Path) -> Option<Vec<PathBuf>> {
94        let output = std::process::Command::new("git")
95            .args(["diff", "--name-only", "HEAD"])
96            .current_dir(project_root)
97            .output()
98            .ok()?;
99
100        if !output.status.success() {
101            return None;
102        }
103
104        let stdout = String::from_utf8_lossy(&output.stdout);
105        let files: Vec<PathBuf> = stdout
106            .lines()
107            .filter(|line| !line.is_empty())
108            .map(PathBuf::from)
109            .collect();
110
111        // Also include untracked files
112        let untracked = std::process::Command::new("git")
113            .args(["ls-files", "--others", "--exclude-standard"])
114            .current_dir(project_root)
115            .output()
116            .ok()?;
117
118        if untracked.status.success() {
119            let untracked_stdout = String::from_utf8_lossy(&untracked.stdout);
120            let mut all_files = files;
121            all_files.extend(
122                untracked_stdout
123                    .lines()
124                    .filter(|line| !line.is_empty())
125                    .map(PathBuf::from),
126            );
127            Some(all_files)
128        } else {
129            Some(files)
130        }
131    }
132}
133
134/// Compute SHA-256 hash of file content.
135pub fn compute_hash(content: &str) -> String {
136    let mut hasher = Sha256::new();
137    hasher.update(content.as_bytes());
138    format!("{:x}", hasher.finalize())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::types::*;
145    use std::path::PathBuf;
146
147    #[test]
148    fn test_compute_hash_deterministic() {
149        let h1 = compute_hash("hello world");
150        let h2 = compute_hash("hello world");
151        assert_eq!(h1, h2);
152    }
153
154    #[test]
155    fn test_compute_hash_different_content() {
156        let h1 = compute_hash("hello");
157        let h2 = compute_hash("world");
158        assert_ne!(h1, h2);
159    }
160
161    #[test]
162    fn test_cache_is_stale() {
163        let mut cache = AnalysisCache::new();
164        cache.files.insert(
165            "test.go".to_string(),
166            CachedFileResult {
167                hash: compute_hash("original content"),
168                components: vec![],
169                dependencies: vec![],
170            },
171        );
172
173        assert!(!cache.is_stale("test.go", "original content"));
174        assert!(cache.is_stale("test.go", "modified content"));
175        assert!(cache.is_stale("nonexistent.go", "anything"));
176    }
177
178    #[test]
179    fn test_cache_get() {
180        let mut cache = AnalysisCache::new();
181        let component = Component {
182            id: ComponentId::new("pkg", "Test"),
183            name: "Test".to_string(),
184            kind: ComponentKind::Entity(EntityInfo {
185                name: "Test".to_string(),
186                fields: vec![],
187                methods: vec![],
188                is_active_record: false,
189            }),
190            layer: None,
191            location: SourceLocation {
192                file: PathBuf::from("test.go"),
193                line: 1,
194                column: 1,
195            },
196            is_cross_cutting: false,
197            architecture_mode: ArchitectureMode::Ddd,
198        };
199
200        cache.insert(
201            "test.go".to_string(),
202            "content",
203            CachedFileResult {
204                hash: String::new(), // will be overwritten
205                components: vec![component],
206                dependencies: vec![],
207            },
208        );
209
210        let result = cache.get("test.go", "content");
211        assert!(result.is_some());
212        assert_eq!(result.unwrap().components.len(), 1);
213
214        let result = cache.get("test.go", "changed");
215        assert!(result.is_none());
216    }
217
218    #[test]
219    fn test_cache_prune() {
220        let mut cache = AnalysisCache::new();
221        cache.files.insert(
222            "a.go".to_string(),
223            CachedFileResult {
224                hash: "h1".to_string(),
225                components: vec![],
226                dependencies: vec![],
227            },
228        );
229        cache.files.insert(
230            "b.go".to_string(),
231            CachedFileResult {
232                hash: "h2".to_string(),
233                components: vec![],
234                dependencies: vec![],
235            },
236        );
237
238        cache.prune(&["a.go".to_string()]);
239        assert!(cache.files.contains_key("a.go"));
240        assert!(!cache.files.contains_key("b.go"));
241    }
242
243    #[test]
244    fn test_cache_save_and_load() {
245        let dir = tempfile::tempdir().unwrap();
246        let mut cache = AnalysisCache::new();
247        cache.insert(
248            "test.go".to_string(),
249            "content",
250            CachedFileResult {
251                hash: String::new(),
252                components: vec![],
253                dependencies: vec![],
254            },
255        );
256
257        cache.save(dir.path()).unwrap();
258        let loaded = AnalysisCache::load(dir.path()).unwrap();
259        assert_eq!(loaded.files.len(), 1);
260        assert!(loaded.files.contains_key("test.go"));
261    }
262}