Skip to main content

codesynapse_core/
cache.rs

1use crate::types::ExtractionFragment;
2use serde_json::Value;
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6pub struct FileCache {
7    cache_dir: PathBuf,
8}
9
10impl FileCache {
11    pub fn new(cache_dir: PathBuf) -> Self {
12        Self { cache_dir }
13    }
14
15    pub fn from_output_dir(output_dir: &Path) -> Self {
16        Self {
17            cache_dir: output_dir.join("cache"),
18        }
19    }
20
21    pub fn cache_path(&self, hash: &str) -> PathBuf {
22        self.cache_dir.join(format!("{}.json", hash))
23    }
24
25    pub fn compute_hash(content: &[u8]) -> String {
26        let mut hasher = Sha256::new();
27        hasher.update(content);
28        format!("{:x}", hasher.finalize())
29    }
30
31    pub fn get_cached(&self, hash: &str) -> Option<ExtractionFragment> {
32        let path = self.cache_path(hash);
33        if !path.exists() {
34            return None;
35        }
36        match std::fs::read_to_string(&path) {
37            Ok(json) => serde_json::from_str(&json).ok(),
38            Err(_) => None,
39        }
40    }
41
42    pub fn set_cached(
43        &self,
44        hash: &str,
45        fragment: &ExtractionFragment,
46    ) -> Result<(), Box<dyn std::error::Error>> {
47        std::fs::create_dir_all(&self.cache_dir)?;
48        let path = self.cache_path(hash);
49        let json = serde_json::to_string(fragment)?;
50        std::fs::write(&path, json)?;
51        Ok(())
52    }
53
54    pub fn is_cached(&self, hash: &str) -> bool {
55        self.cache_path(hash).exists()
56    }
57
58    pub fn clear(&self) -> Result<(), std::io::Error> {
59        if self.cache_dir.exists() {
60            std::fs::remove_dir_all(&self.cache_dir)?;
61        }
62        Ok(())
63    }
64}
65
66/// Check semantic cache for a list of file paths.
67/// Returns (cached_nodes, cached_edges, cached_hyperedges, uncached_files).
68/// Cache location: `<root>/codesynapse-out/cache/semantic/<sha256>.json`
69pub fn check_semantic_cache(
70    files: &[&str],
71    root: &Path,
72) -> (Vec<Value>, Vec<Value>, Vec<Value>, Vec<String>) {
73    let cache_dir = root.join("codesynapse-out").join("cache").join("semantic");
74    let mut cached_nodes: Vec<Value> = Vec::new();
75    let mut cached_edges: Vec<Value> = Vec::new();
76    let mut cached_hyperedges: Vec<Value> = Vec::new();
77    let mut uncached: Vec<String> = Vec::new();
78
79    for &fpath in files {
80        let p = if std::path::Path::new(fpath).is_absolute() {
81            std::path::PathBuf::from(fpath)
82        } else {
83            root.join(fpath)
84        };
85        let content = match std::fs::read(&p) {
86            Ok(c) => c,
87            Err(_) => {
88                uncached.push(fpath.to_string());
89                continue;
90            }
91        };
92        let hash = FileCache::compute_hash(&content);
93        let entry = cache_dir.join(format!("{}.json", hash));
94        let hit = entry
95            .exists()
96            .then(|| std::fs::read_to_string(&entry).ok())
97            .flatten()
98            .and_then(|t| serde_json::from_str::<Value>(&t).ok());
99        match hit {
100            Some(result) => {
101                if let Some(arr) = result.get("nodes").and_then(|v| v.as_array()) {
102                    cached_nodes.extend(arr.iter().cloned());
103                }
104                if let Some(arr) = result.get("edges").and_then(|v| v.as_array()) {
105                    cached_edges.extend(arr.iter().cloned());
106                }
107                if let Some(arr) = result.get("hyperedges").and_then(|v| v.as_array()) {
108                    cached_hyperedges.extend(arr.iter().cloned());
109                }
110            }
111            None => uncached.push(fpath.to_string()),
112        }
113    }
114
115    (cached_nodes, cached_edges, cached_hyperedges, uncached)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::types::{Edge, Node};
122    use std::collections::HashMap;
123
124    fn test_fragment() -> ExtractionFragment {
125        ExtractionFragment {
126            nodes: vec![Node {
127                id: "test_node".into(),
128                label: "TestNode".into(),
129                file_type: "code".into(),
130                source_file: "test.py".into(),
131                source_location: Some("1:1".into()),
132                community: None,
133                rationale: None,
134                docstring: None,
135                metadata: HashMap::new(),
136            }],
137            edges: vec![Edge {
138                source: "a".into(),
139                target: "b".into(),
140                relation: "calls".into(),
141                confidence: "EXTRACTED".into(),
142                source_file: Some("test.py".into()),
143                weight: 1.0,
144                context: None,
145            }],
146        }
147    }
148
149    #[test]
150    fn test_compute_hash_deterministic() {
151        let h1 = FileCache::compute_hash(b"hello world");
152        let h2 = FileCache::compute_hash(b"hello world");
153        assert_eq!(h1, h2);
154        assert_eq!(h1.len(), 64);
155    }
156
157    #[test]
158    fn test_compute_hash_different_inputs() {
159        let h1 = FileCache::compute_hash(b"hello");
160        let h2 = FileCache::compute_hash(b"world");
161        assert_ne!(h1, h2);
162    }
163
164    #[test]
165    fn test_cache_roundtrip() {
166        let dir = std::env::temp_dir().join("codesynapse-test-cache-roundtrip");
167        let _ = std::fs::remove_dir_all(&dir);
168        let cache = FileCache::from_output_dir(&dir);
169        let fragment = test_fragment();
170        let hash = FileCache::compute_hash(b"test content");
171
172        cache.set_cached(&hash, &fragment).unwrap();
173        assert!(cache.is_cached(&hash));
174
175        let loaded = cache.get_cached(&hash).unwrap();
176        assert_eq!(loaded.nodes.len(), 1);
177        assert_eq!(loaded.nodes[0].id, "test_node");
178        assert_eq!(loaded.edges.len(), 1);
179        assert_eq!(loaded.edges[0].relation, "calls");
180
181        let _ = std::fs::remove_dir_all(&dir);
182    }
183
184    #[test]
185    fn test_cache_miss() {
186        let dir = std::env::temp_dir().join("codesynapse-test-cache-miss");
187        let _ = std::fs::remove_dir_all(&dir);
188        let cache = FileCache::from_output_dir(&dir);
189        assert!(!cache.is_cached("nonexistent_hash"));
190        assert!(cache.get_cached("nonexistent_hash").is_none());
191        let _ = std::fs::remove_dir_all(&dir);
192    }
193
194    #[test]
195    fn test_cache_clear() {
196        let dir = std::env::temp_dir().join("codesynapse-test-cache-clear");
197        let _ = std::fs::remove_dir_all(&dir);
198        let cache = FileCache::from_output_dir(&dir);
199        let fragment = test_fragment();
200        let hash = FileCache::compute_hash(b"clear test");
201
202        cache.set_cached(&hash, &fragment).unwrap();
203        assert!(cache.is_cached(&hash));
204
205        cache.clear().unwrap();
206        assert!(!cache.is_cached(&hash));
207        let _ = std::fs::remove_dir_all(&dir);
208    }
209}