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#[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#[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 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 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 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, }
64 }
65
66 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 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 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 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 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
134pub 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(), 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}