1use super::structural::StructuralGraph;
14use blake3;
15#[cfg(not(target_arch = "wasm32"))]
16use fs2::FileExt;
17#[cfg(not(target_arch = "wasm32"))]
18use std::io::Write;
19use std::path::{Path, PathBuf};
20#[cfg(not(target_arch = "wasm32"))]
21use tempfile::NamedTempFile;
22#[cfg(not(target_arch = "wasm32"))]
23use tracing::warn;
24
25#[cfg(not(target_arch = "wasm32"))]
26const FORMAT_VERSION: u32 = 2;
27pub const DEFAULT_MAX_DISK_CACHE_BYTES: u64 = 512 * 1024 * 1024;
28
29#[cfg(not(target_arch = "wasm32"))]
30struct ShardLockGuard {
31 _file: std::fs::File,
32}
33#[cfg(not(target_arch = "wasm32"))]
36fn lock_shard_shared(shard_dir: &Path) -> Option<ShardLockGuard> {
37 let lock_path = shard_dir.join(".lock");
38 let file = std::fs::OpenOptions::new()
39 .create(true)
40 .write(true)
41 .truncate(false)
42 .open(&lock_path)
43 .ok()?;
44 file.lock_shared().map_err(|e| {
45 warn!(error = %e, lock_path = %lock_path.display(), "graph store: shared lock failed")
46 }).ok()?;
47 Some(ShardLockGuard { _file: file })
48}
49
50#[cfg(not(target_arch = "wasm32"))]
53fn lock_shard_exclusive(shard_dir: &Path) -> Result<ShardLockGuard, std::io::Error> {
54 let lock_path = shard_dir.join(".lock");
55 let file = std::fs::OpenOptions::new()
56 .create(true)
57 .write(true)
58 .truncate(false)
59 .open(&lock_path)?;
60 file.lock_exclusive()?;
61 Ok(ShardLockGuard { _file: file })
62}
63
64#[cfg(not(target_arch = "wasm32"))]
65fn write_entry_atomically(dir: &Path, path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
66 let _lock = lock_shard_exclusive(dir)?;
67 let mut tmp = NamedTempFile::new_in(dir)?;
68 tmp.write_all(data)?;
69 tmp.persist(path).map(|_| ()).map_err(|e| e.error)
70}
71
72#[cfg(not(target_arch = "wasm32"))]
73fn evict_lru_if_over_budget(base_dir: &Path, max_bytes: u64) {
74 let mut entries = Vec::new();
76
77 match std::fs::read_dir(base_dir) {
78 Ok(shards) => {
79 for shard_entry in shards.flatten() {
80 let shard_path = shard_entry.path();
81 if !shard_path.is_dir() {
82 continue;
83 }
84
85 if let Ok(files) = std::fs::read_dir(&shard_path) {
87 for file_entry in files.flatten() {
88 let file_path = file_entry.path();
89 if file_path.extension().and_then(|e| e.to_str()) != Some("bin") {
90 continue;
91 }
92
93 if let Ok(metadata) = file_entry.metadata() {
94 entries.push((file_path, metadata.len(), metadata.modified().ok()));
95 }
96 }
97 }
98 }
99 }
100 Err(e) => {
101 warn!(path = %base_dir.display(), error = %e, "graph store: failed to read base dir for eviction");
102 return;
103 }
104 }
105
106 let total_size: u64 = entries.iter().map(|(_, len, _)| len).sum();
108 if total_size <= max_bytes {
109 return;
110 }
111
112 entries.sort_by(|a, b| {
114 match (&a.2, &b.2) {
116 (None, None) => std::cmp::Ordering::Equal,
117 (None, Some(_)) => std::cmp::Ordering::Less,
118 (Some(_), None) => std::cmp::Ordering::Greater,
119 (Some(at), Some(bt)) => at.cmp(bt),
120 }
121 });
122
123 let mut current_size = total_size;
125 for (path, size, _) in entries {
126 if current_size <= max_bytes {
127 break;
128 }
129
130 let shard_dir = path.parent().unwrap_or(base_dir);
132 match lock_shard_exclusive(shard_dir) {
133 Ok(_lock) => {
134 if let Err(e) = std::fs::remove_file(&path) {
135 warn!(key = %path.display(), error = %e, "graph store: eviction remove_file failed");
136 } else {
137 current_size = current_size.saturating_sub(size);
138 }
139 }
140 Err(e) => {
141 warn!(shard = %shard_dir.display(), error = %e, "graph store: eviction lock failed");
142 continue;
143 }
144 }
145 }
146}
147
148pub struct GraphDiskStore {
149 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
150 base_dir: PathBuf,
151 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
152 max_bytes: u64,
153}
154
155impl GraphDiskStore {
156 pub fn new(base_dir: PathBuf) -> Self {
157 Self::new_with_max_bytes(base_dir, DEFAULT_MAX_DISK_CACHE_BYTES)
158 }
159
160 #[cfg(not(target_arch = "wasm32"))]
161 pub fn new_with_max_bytes(base_dir: PathBuf, max_bytes: u64) -> Self {
162 if let Err(e) = std::fs::create_dir_all(&base_dir) {
163 warn!(path = %base_dir.display(), error = %e, "graph store: failed to create base dir");
164 }
165 GraphDiskStore {
166 base_dir,
167 max_bytes,
168 }
169 }
170
171 #[cfg(target_arch = "wasm32")]
173 pub fn new_with_max_bytes(base_dir: PathBuf, max_bytes: u64) -> Self {
174 GraphDiskStore {
175 base_dir,
176 max_bytes,
177 }
178 }
179
180 pub fn cache_key(root: &Path, file_hashes: &[(PathBuf, blake3::Hash)]) -> String {
181 let mut hasher = blake3::Hasher::new();
182 hasher.update(root.to_string_lossy().as_bytes());
183 let mut sorted: Vec<&(PathBuf, blake3::Hash)> = file_hashes.iter().collect();
184 sorted.sort_by(|a, b| a.0.cmp(&b.0));
185 for (path, hash) in &sorted {
186 hasher.update(path.to_string_lossy().as_bytes());
187 hasher.update(hash.as_bytes());
188 }
189 hasher.finalize().to_string()
190 }
191
192 #[cfg(not(target_arch = "wasm32"))]
193 fn entry_path(&self, key: &str) -> PathBuf {
194 self.base_dir.join(&key[..2]).join(format!("{}.bin", key))
195 }
196
197 #[cfg(not(target_arch = "wasm32"))]
198 pub fn get(&self, key: &str) -> Option<StructuralGraph> {
199 let path = self.entry_path(key);
200 let dir = path.parent()?;
201 let _lock = lock_shard_shared(dir)?;
202 let data = std::fs::read(&path).ok()?;
203 if data.len() < 4 {
204 return None;
205 }
206 let (hdr, payload) = data.split_at(4);
207 if u32::from_le_bytes(<[u8; 4]>::try_from(hdr).ok()?) != FORMAT_VERSION {
208 warn!(key, "graph store: format version mismatch");
209 return None;
210 }
211 let mut graph: StructuralGraph = postcard::from_bytes(payload).ok()?;
212 graph.rebuild_symbol_index();
213
214 if let Ok(file) = std::fs::File::options().write(true).open(&path)
216 && let Err(e) = file.set_modified(std::time::SystemTime::now())
217 {
218 warn!(key, error = %e, "graph store: failed to touch mtime on read");
219 }
220
221 Some(graph)
222 }
223
224 #[cfg(target_arch = "wasm32")]
226 pub fn get(&self, _key: &str) -> Option<StructuralGraph> {
227 None
228 }
229
230 #[cfg(not(target_arch = "wasm32"))]
231 pub fn put(&self, key: &str, graph: &StructuralGraph) {
232 let payload = match postcard::to_allocvec(graph) {
233 Ok(p) => p,
234 Err(e) => {
235 warn!(key, error = %e, "graph store: serialize failed");
236 return;
237 }
238 };
239 let mut data = Vec::with_capacity(4 + payload.len());
240 data.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
241 data.extend_from_slice(&payload);
242 let path = self.entry_path(key);
243 let Some(dir) = path.parent().map(|d| d.to_path_buf()) else {
244 return;
245 };
246 if let Err(e) = std::fs::create_dir_all(&dir) {
247 warn!(key, error = %e, "graph store: mkdir failed");
248 return;
249 }
250 if let Err(e) = write_entry_atomically(&dir, &path, &data) {
251 warn!(key, error = %e, "graph store: write failed");
252 return;
253 }
254
255 evict_lru_if_over_budget(&self.base_dir, self.max_bytes);
257 }
258
259 #[cfg(target_arch = "wasm32")]
261 pub fn put(&self, _key: &str, _graph: &StructuralGraph) {}
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use tempfile::TempDir;
268
269 fn make_test_graph() -> StructuralGraph {
270 use crate::graph::structural::Node;
271 let mut g = petgraph::graph::DiGraph::new();
272 g.add_node(Node::File {
273 path: "t.rs".into(),
274 });
275 StructuralGraph::from_graph(g)
276 }
277
278 #[test]
279 fn test_put_and_get_roundtrip() {
280 let tmp = TempDir::new().expect("temp dir");
281 let store = GraphDiskStore::new(tmp.path().to_path_buf());
282 let graph = make_test_graph();
283 store.put("key1", &graph);
284 let got = store.get("key1");
285 assert!(got.is_some());
286 assert_eq!(got.unwrap().graph.node_count(), 1);
287 }
288
289 #[test]
290 fn test_get_version_mismatch_returns_none() {
291 let tmp = TempDir::new().expect("temp dir");
292 let store = GraphDiskStore::new(tmp.path().to_path_buf());
293 let key = "vm";
294 let dir = tmp.path().join(&key[..2]);
295 let path = dir.join(format!("{}.bin", key));
296 std::fs::create_dir_all(&dir).ok();
297 let mut data = 99u32.to_le_bytes().to_vec();
298 data.extend_from_slice(b"x");
299 std::fs::write(&path, &data).ok();
300 assert!(store.get(key).is_none());
301 }
302
303 #[test]
304 fn test_eviction_by_lru_mtime() {
305 let tmp = TempDir::new().expect("temp dir");
306 let graph = make_test_graph();
307
308 let probe_store = GraphDiskStore::new(tmp.path().join("probe"));
312 probe_store.put("probe_key", &graph);
313 let entry_size = std::fs::metadata(probe_store.entry_path("probe_key"))
314 .expect("probe entry metadata")
315 .len();
316
317 let store = GraphDiskStore::new_with_max_bytes(tmp.path().join("cache"), entry_size + 1);
319
320 store.put("aaa_key1", &graph);
321 std::thread::sleep(std::time::Duration::from_millis(20));
322 store.put("aaa_key2", &graph);
323 std::thread::sleep(std::time::Duration::from_millis(20));
324 store.put("aaa_key3", &graph);
325
326 assert!(
327 store.get("aaa_key1").is_none(),
328 "oldest entry should have been evicted"
329 );
330 assert!(
331 store.get("aaa_key2").is_none(),
332 "middle entry should have been evicted"
333 );
334 assert!(
335 store.get("aaa_key3").is_some(),
336 "newest entry should survive eviction"
337 );
338 }
339
340 #[test]
341 fn test_get_touches_mtime() {
342 let tmp = TempDir::new().expect("temp dir");
343 let store = GraphDiskStore::new(tmp.path().to_path_buf());
344 let graph = make_test_graph();
345
346 store.put("touch_key", &graph);
347 let path = store.entry_path("touch_key");
348
349 let initial_mtime = std::fs::metadata(&path)
351 .ok()
352 .and_then(|m| m.modified().ok());
353
354 std::thread::sleep(std::time::Duration::from_millis(10));
356
357 let _graph = store.get("touch_key");
359
360 let new_mtime = std::fs::metadata(&path)
362 .ok()
363 .and_then(|m| m.modified().ok());
364
365 if let (Some(im), Some(nm)) = (initial_mtime, new_mtime) {
367 assert!(nm >= im, "mtime should advance or stay same on read");
368 } }
370}