Skip to main content

codesynapse_core/
global_graph.rs

1use crate::types::{Edge, GraphData, Node};
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7const MAX_GRAPH_FILE_BYTES: u64 = 512 * 1024 * 1024;
8
9#[derive(Debug, Clone)]
10pub struct PrefixedGraph {
11    pub nodes: Vec<Node>,
12    pub edges: Vec<Edge>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct RepoManifestEntry {
17    pub added_at: String,
18    pub source_path: String,
19    pub node_count: usize,
20    pub edge_count: usize,
21    pub source_hash: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct GlobalManifest {
26    pub version: u32,
27    pub repos: HashMap<String, RepoManifestEntry>,
28}
29
30impl Default for GlobalManifest {
31    fn default() -> Self {
32        Self {
33            version: 1,
34            repos: HashMap::new(),
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct AddResult {
41    pub repo_tag: String,
42    pub nodes_added: usize,
43    pub nodes_removed: usize,
44    pub skipped: bool,
45}
46
47fn file_hash(path: &Path) -> crate::error::Result<String> {
48    use sha2::{Digest, Sha256};
49    let bytes = std::fs::read(path)?;
50    let hash = Sha256::digest(&bytes);
51    Ok(format!("{:x}", hash)[..16].to_string())
52}
53
54fn global_graph_path(global_dir: &Path) -> PathBuf {
55    global_dir.join("global-graph.json")
56}
57
58fn global_manifest_path(global_dir: &Path) -> PathBuf {
59    global_dir.join("global-manifest.json")
60}
61
62fn load_manifest(global_dir: &Path) -> GlobalManifest {
63    let path = global_manifest_path(global_dir);
64    if path.exists() {
65        if let Ok(text) = std::fs::read_to_string(&path) {
66            if let Ok(m) = serde_json::from_str::<GlobalManifest>(&text) {
67                return m;
68            }
69        }
70    }
71    GlobalManifest::default()
72}
73
74fn save_manifest(global_dir: &Path, manifest: &GlobalManifest) -> crate::error::Result<()> {
75    std::fs::create_dir_all(global_dir)?;
76    let text = serde_json::to_string_pretty(manifest)
77        .map_err(crate::error::CodeSynapseError::Serialization)?;
78    std::fs::write(global_manifest_path(global_dir), text)?;
79    Ok(())
80}
81
82fn load_global_graph(global_dir: &Path) -> crate::error::Result<GraphData> {
83    let path = global_graph_path(global_dir);
84    if !path.exists() {
85        return Ok(GraphData {
86            nodes: vec![],
87            edges: vec![],
88            hyperedges: None,
89        });
90    }
91    let size = std::fs::metadata(&path)?.len();
92    if size > MAX_GRAPH_FILE_BYTES {
93        return Err(crate::error::CodeSynapseError::Validation(format!(
94            "global graph exceeds {} byte limit",
95            MAX_GRAPH_FILE_BYTES
96        )));
97    }
98    let text = std::fs::read_to_string(&path)?;
99    let v: serde_json::Value =
100        serde_json::from_str(&text).map_err(crate::error::CodeSynapseError::Serialization)?;
101
102    let nodes: Vec<Node> = v
103        .get("nodes")
104        .and_then(|n| n.as_array())
105        .map(|arr| {
106            arr.iter()
107                .filter_map(|item| serde_json::from_value(item.clone()).ok())
108                .collect()
109        })
110        .unwrap_or_default();
111
112    let edge_arr = v
113        .get("edges")
114        .or_else(|| v.get("links"))
115        .and_then(|e| e.as_array());
116    let edges: Vec<Edge> = edge_arr
117        .map(|arr| {
118            arr.iter()
119                .filter_map(|item| serde_json::from_value(item.clone()).ok())
120                .collect()
121        })
122        .unwrap_or_default();
123
124    Ok(GraphData {
125        nodes,
126        edges,
127        hyperedges: None,
128    })
129}
130
131fn save_global_graph(global_dir: &Path, graph: &GraphData) -> crate::error::Result<()> {
132    std::fs::create_dir_all(global_dir)?;
133    let data = json!({
134        "nodes": graph.nodes,
135        "edges": graph.edges,
136    });
137    let text = serde_json::to_string_pretty(&data)
138        .map_err(crate::error::CodeSynapseError::Serialization)?;
139    std::fs::write(global_graph_path(global_dir), text)?;
140    Ok(())
141}
142
143pub fn prefix_graph(graph: &GraphData, repo_tag: &str) -> PrefixedGraph {
144    let prefixed_nodes: Vec<Node> = graph
145        .nodes
146        .iter()
147        .map(|n| {
148            let mut meta = n.metadata.clone();
149            meta.insert("repo".to_string(), repo_tag.to_string());
150            meta.insert("local_id".to_string(), n.id.clone());
151            Node {
152                id: format!("{}::{}", repo_tag, n.id),
153                label: n.label.clone(),
154                file_type: n.file_type.clone(),
155                source_file: n.source_file.clone(),
156                source_location: n.source_location.clone(),
157                community: n.community,
158                rationale: n.rationale.clone(),
159                docstring: None,
160                metadata: meta,
161            }
162        })
163        .collect();
164
165    let prefixed_edges: Vec<Edge> = graph
166        .edges
167        .iter()
168        .map(|e| Edge {
169            source: format!("{}::{}", repo_tag, e.source),
170            target: format!("{}::{}", repo_tag, e.target),
171            relation: e.relation.clone(),
172            confidence: e.confidence.clone(),
173            source_file: e.source_file.clone(),
174            weight: e.weight,
175            context: None,
176        })
177        .collect();
178
179    PrefixedGraph {
180        nodes: prefixed_nodes,
181        edges: prefixed_edges,
182    }
183}
184
185pub fn prune_repo_from_graph(graph: &mut GraphData, repo_tag: &str) -> usize {
186    let prefix = format!("{}::", repo_tag);
187    let _before = graph.nodes.len();
188    let remove_ids: HashSet<String> = graph
189        .nodes
190        .iter()
191        .filter(|n| {
192            n.id.starts_with(&prefix)
193                || n.metadata.get("repo").map(|s| s.as_str()) == Some(repo_tag)
194        })
195        .map(|n| n.id.clone())
196        .collect();
197    graph.nodes.retain(|n| !remove_ids.contains(&n.id));
198    graph
199        .edges
200        .retain(|e| !remove_ids.contains(&e.source) && !remove_ids.contains(&e.target));
201    remove_ids.len()
202}
203
204pub fn global_add(
205    source_path: &Path,
206    repo_tag: &str,
207    global_dir: &Path,
208) -> crate::error::Result<AddResult> {
209    global_add_force(source_path, repo_tag, global_dir, false)
210}
211
212pub fn global_add_force(
213    source_path: &Path,
214    repo_tag: &str,
215    global_dir: &Path,
216    force: bool,
217) -> crate::error::Result<AddResult> {
218    if !source_path.exists() {
219        return Err(crate::error::CodeSynapseError::Io(std::io::Error::new(
220            std::io::ErrorKind::NotFound,
221            format!("graph not found: {}", source_path.display()),
222        )));
223    }
224
225    let size = std::fs::metadata(source_path)?.len();
226    if size > MAX_GRAPH_FILE_BYTES {
227        return Err(crate::error::CodeSynapseError::Validation(format!(
228            "source graph exceeds {} byte limit",
229            MAX_GRAPH_FILE_BYTES
230        )));
231    }
232
233    let mut manifest = load_manifest(global_dir);
234    let src_hash = file_hash(source_path)?;
235
236    let existing = manifest.repos.get(repo_tag);
237    if let Some(existing) = existing {
238        let canonical = source_path
239            .canonicalize()
240            .map(|p| p.display().to_string())
241            .unwrap_or_else(|_| source_path.display().to_string());
242        if !existing.source_path.is_empty() && existing.source_path != canonical {
243            eprintln!(
244                "[codesynapse global] warning: repo tag '{}' previously pointed to {:?}, now updating to {:?}. Use --as <tag> to give it a different name.",
245                repo_tag, existing.source_path, canonical
246            );
247        }
248        if !force && existing.source_hash == src_hash {
249            return Ok(AddResult {
250                repo_tag: repo_tag.to_string(),
251                nodes_added: 0,
252                nodes_removed: 0,
253                skipped: true,
254            });
255        }
256    }
257
258    let text = std::fs::read_to_string(source_path)?;
259    let v: serde_json::Value =
260        serde_json::from_str(&text).map_err(crate::error::CodeSynapseError::Serialization)?;
261
262    let src_nodes: Vec<Node> = v
263        .get("nodes")
264        .and_then(|n| n.as_array())
265        .map(|arr| {
266            arr.iter()
267                .filter_map(|item| serde_json::from_value(item.clone()).ok())
268                .collect()
269        })
270        .unwrap_or_default();
271    let edge_arr = v
272        .get("edges")
273        .or_else(|| v.get("links"))
274        .and_then(|e| e.as_array());
275    let src_edges: Vec<Edge> = edge_arr
276        .map(|arr| {
277            arr.iter()
278                .filter_map(|item| serde_json::from_value(item.clone()).ok())
279                .collect()
280        })
281        .unwrap_or_default();
282
283    let src_graph = GraphData {
284        nodes: src_nodes,
285        edges: src_edges,
286        hyperedges: None,
287    };
288    let prefixed = prefix_graph(&src_graph, repo_tag);
289    let edge_count = prefixed.edges.len();
290
291    let mut global = load_global_graph(global_dir)?;
292    let removed = prune_repo_from_graph(&mut global, repo_tag);
293
294    let external_labels: HashMap<String, String> = global
295        .nodes
296        .iter()
297        .filter(|n| n.source_file.is_empty() && !n.label.is_empty())
298        .map(|n| (n.label.clone(), n.id.clone()))
299        .collect();
300
301    let skip_ids: HashSet<String> = prefixed
302        .nodes
303        .iter()
304        .filter(|n| n.source_file.is_empty() && external_labels.contains_key(&n.label))
305        .map(|n| n.id.clone())
306        .collect();
307
308    let added = prefixed.nodes.len() - skip_ids.len();
309    for node in &prefixed.nodes {
310        if !skip_ids.contains(&node.id) {
311            global.nodes.push(node.clone());
312        }
313    }
314    for edge in &prefixed.edges {
315        if !skip_ids.contains(&edge.source) && !skip_ids.contains(&edge.target) {
316            global.edges.push(edge.clone());
317        }
318    }
319
320    save_global_graph(global_dir, &global)?;
321
322    let canonical = source_path
323        .canonicalize()
324        .map(|p| p.display().to_string())
325        .unwrap_or_else(|_| source_path.display().to_string());
326
327    manifest.repos.insert(
328        repo_tag.to_string(),
329        RepoManifestEntry {
330            added_at: chrono::Utc::now().to_rfc3339(),
331            source_path: canonical,
332            node_count: added,
333            edge_count,
334            source_hash: src_hash,
335        },
336    );
337    save_manifest(global_dir, &manifest)?;
338
339    Ok(AddResult {
340        repo_tag: repo_tag.to_string(),
341        nodes_added: added,
342        nodes_removed: removed,
343        skipped: false,
344    })
345}
346
347pub fn global_remove(repo_tag: &str, global_dir: &Path) -> crate::error::Result<usize> {
348    let mut manifest = load_manifest(global_dir);
349    if !manifest.repos.contains_key(repo_tag) {
350        return Err(crate::error::CodeSynapseError::Validation(format!(
351            "repo '{}' not in global graph",
352            repo_tag
353        )));
354    }
355
356    let mut global = load_global_graph(global_dir)?;
357    let removed = prune_repo_from_graph(&mut global, repo_tag);
358    save_global_graph(global_dir, &global)?;
359
360    manifest.repos.remove(repo_tag);
361    save_manifest(global_dir, &manifest)?;
362
363    Ok(removed)
364}
365
366pub fn global_list(global_dir: &Path) -> HashMap<String, RepoManifestEntry> {
367    load_manifest(global_dir).repos
368}
369
370/// Generate dense embeddings for all nodes in the global graph and write to
371/// `~/.codesynapse/embeddings.json`. Skipped (returns `Ok(0)`) when the model
372/// is not installed or when `embeddings.json` is already newer than
373/// `global-graph.json` (mtime-gated). Returns the number of nodes embedded.
374pub fn embed_global_graph(global_dir: &Path) -> crate::error::Result<usize> {
375    let model_path = global_dir.join("models").join("potion-code-16M");
376    if !model_path.exists() {
377        return Ok(0);
378    }
379
380    let graph_path = global_graph_path(global_dir);
381    let embed_path = global_dir.join("embeddings.json");
382
383    // mtime gate: skip if embeddings are newer than the graph
384    if embed_path.exists() && graph_path.exists() {
385        let graph_mtime = std::fs::metadata(&graph_path)
386            .and_then(|m| m.modified())
387            .ok();
388        let embed_mtime = std::fs::metadata(&embed_path)
389            .and_then(|m| m.modified())
390            .ok();
391        if let (Some(g), Some(e)) = (graph_mtime, embed_mtime) {
392            if e >= g {
393                return Ok(0);
394            }
395        }
396    }
397
398    let graph = load_global_graph(global_dir)?;
399    if graph.nodes.is_empty() {
400        return Ok(0);
401    }
402
403    // Build child-label lookup for CONTAINS edges (method names per class)
404    let mut child_labels: HashMap<String, Vec<String>> = HashMap::new();
405    for edge in &graph.edges {
406        if edge.relation == "contains" || edge.relation == "CONTAINS" {
407            if let Some(target) = graph.nodes.iter().find(|n| n.id == edge.target) {
408                child_labels
409                    .entry(edge.source.clone())
410                    .or_default()
411                    .push(target.label.clone());
412            }
413        }
414    }
415
416    // Build (id, text) pairs — label + docstring + source_file + child labels, capped at 1024 chars
417    let pairs: Vec<(String, String)> = graph
418        .nodes
419        .iter()
420        .map(|n| {
421            let docstring_str = n.docstring.as_deref().unwrap_or("");
422            let methods = child_labels
423                .get(&n.id)
424                .map(|v| v.join(" "))
425                .unwrap_or_default();
426            let text = format!(
427                "{} {} {} {}",
428                n.label, docstring_str, n.source_file, methods
429            );
430            let text = text.trim().to_string();
431            let text = if text.len() > 1024 {
432                text[..1024].to_string()
433            } else {
434                text
435            };
436            (n.id.clone(), text)
437        })
438        .collect();
439
440    let embedder = crate::embedding::StaticEmbedder::from_path(&model_path)
441        .map_err(crate::error::CodeSynapseError::Validation)?;
442
443    let refs: Vec<(&str, &str)> = pairs
444        .iter()
445        .map(|(id, txt)| (id.as_str(), txt.as_str()))
446        .collect();
447    let embeddings = embedder.embed_nodes(&refs);
448
449    let text = serde_json::to_string(&embeddings)
450        .map_err(crate::error::CodeSynapseError::Serialization)?;
451    std::fs::write(&embed_path, text)?;
452
453    Ok(embeddings.len())
454}
455
456pub fn check_cross_repo_guard(nodes: &[Node]) -> crate::error::Result<()> {
457    let repos: HashSet<&str> = nodes
458        .iter()
459        .filter_map(|n| n.metadata.get("repo").map(|s| s.as_str()))
460        .filter(|s| !s.is_empty())
461        .collect();
462    if repos.len() > 1 {
463        return Err(crate::error::CodeSynapseError::Validation(format!(
464            "nodes span multiple repos: {:?} — dedup requires a single-repo input",
465            repos
466        )));
467    }
468    Ok(())
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::types::{Edge, Node};
475    use std::collections::HashMap;
476
477    fn make_node(id: &str, label: &str, source_file: &str) -> Node {
478        Node {
479            id: id.into(),
480            label: label.into(),
481            file_type: "code".into(),
482            source_file: source_file.into(),
483            source_location: None,
484            community: None,
485            rationale: None,
486            docstring: None,
487            metadata: HashMap::new(),
488        }
489    }
490
491    fn make_node_with_repo(id: &str, label: &str, repo: &str) -> Node {
492        let mut meta = HashMap::new();
493        meta.insert("repo".to_string(), repo.to_string());
494        Node {
495            id: id.into(),
496            label: label.into(),
497            file_type: "code".into(),
498            source_file: "src/x.py".into(),
499            source_location: None,
500            community: None,
501            rationale: None,
502            docstring: None,
503            metadata: meta,
504        }
505    }
506
507    fn make_edge(src: &str, tgt: &str) -> Edge {
508        Edge {
509            source: src.into(),
510            target: tgt.into(),
511            relation: "calls".into(),
512            confidence: "EXTRACTED".into(),
513            source_file: None,
514            weight: 1.0,
515            context: None,
516        }
517    }
518
519    fn simple_graph() -> GraphData {
520        GraphData {
521            nodes: vec![make_node("userservice", "UserService", "src/user.py")],
522            edges: vec![],
523            hyperedges: None,
524        }
525    }
526
527    #[test]
528    fn test_prefix_graph_preserves_label() {
529        let g = simple_graph();
530        let h = prefix_graph(&g, "repoA");
531        assert!(h.nodes.iter().any(|n| n.id == "repoA::userservice"));
532        assert!(!h.nodes.iter().any(|n| n.id == "userservice"));
533        let node = h
534            .nodes
535            .iter()
536            .find(|n| n.id == "repoA::userservice")
537            .unwrap();
538        assert_eq!(node.label, "UserService");
539    }
540
541    #[test]
542    fn test_prefix_graph_sets_repo_and_local_id() {
543        let g = simple_graph();
544        let h = prefix_graph(&g, "repoA");
545        let node = h
546            .nodes
547            .iter()
548            .find(|n| n.id == "repoA::userservice")
549            .unwrap();
550        assert_eq!(node.metadata.get("repo").map(|s| s.as_str()), Some("repoA"));
551        assert_eq!(
552            node.metadata.get("local_id").map(|s| s.as_str()),
553            Some("userservice")
554        );
555    }
556
557    #[test]
558    fn test_prefix_graph_rewrites_edges() {
559        let g = GraphData {
560            nodes: vec![make_node("a", "A", "a.py"), make_node("b", "B", "b.py")],
561            edges: vec![make_edge("a", "b")],
562            hyperedges: None,
563        };
564        let h = prefix_graph(&g, "repo1");
565        assert!(h
566            .edges
567            .iter()
568            .any(|e| e.source == "repo1::a" && e.target == "repo1::b"));
569        assert!(!h.edges.iter().any(|e| e.source == "a" && e.target == "b"));
570    }
571
572    #[test]
573    fn test_prune_repo_removes_correct_nodes() {
574        let mut g = GraphData {
575            nodes: vec![
576                make_node_with_repo("repoA::userservice", "UserService", "repoA"),
577                make_node_with_repo("repoB::userservice", "UserService", "repoB"),
578                make_node_with_repo("repoA::auth", "Auth", "repoA"),
579            ],
580            edges: vec![],
581            hyperedges: None,
582        };
583        let removed = prune_repo_from_graph(&mut g, "repoA");
584        assert_eq!(removed, 2);
585        assert!(g.nodes.iter().any(|n| n.id == "repoB::userservice"));
586        assert!(!g.nodes.iter().any(|n| n.id == "repoA::userservice"));
587        assert!(!g.nodes.iter().any(|n| n.id == "repoA::auth"));
588    }
589
590    #[test]
591    fn test_prune_repo_returns_zero_if_not_present() {
592        let mut g = GraphData {
593            nodes: vec![make_node_with_repo("repoA::x", "X", "repoA")],
594            edges: vec![],
595            hyperedges: None,
596        };
597        let removed = prune_repo_from_graph(&mut g, "repoB");
598        assert_eq!(removed, 0);
599        assert_eq!(g.nodes.len(), 1);
600    }
601
602    #[test]
603    fn test_global_add_creates_global_graph() {
604        let tmp = tempfile::tempdir().unwrap();
605        let src_path = tmp.path().join("graph.json");
606        let g = simple_graph();
607        std::fs::write(
608            &src_path,
609            serde_json::to_string(&serde_json::json!({"nodes": g.nodes, "edges": g.edges}))
610                .unwrap(),
611        )
612        .unwrap();
613        let global_dir = tmp.path().join(".codesynapse");
614        let result = global_add(&src_path, "repoA", &global_dir).unwrap();
615        assert!(!result.skipped);
616        assert!(result.nodes_added > 0);
617        assert!(global_manifest_path(&global_dir).exists());
618        let manifest = load_manifest(&global_dir);
619        assert!(manifest.repos.contains_key("repoA"));
620    }
621
622    #[test]
623    fn test_global_add_skip_on_unchanged_hash() {
624        let tmp = tempfile::tempdir().unwrap();
625        let src_path = tmp.path().join("graph.json");
626        let g = simple_graph();
627        std::fs::write(
628            &src_path,
629            serde_json::to_string(&serde_json::json!({"nodes": g.nodes, "edges": g.edges}))
630                .unwrap(),
631        )
632        .unwrap();
633        let global_dir = tmp.path().join(".codesynapse");
634        global_add(&src_path, "repoA", &global_dir).unwrap();
635        let result2 = global_add(&src_path, "repoA", &global_dir).unwrap();
636        assert!(result2.skipped);
637    }
638
639    #[test]
640    fn test_global_add_two_repos_no_collision() {
641        let tmp = tempfile::tempdir().unwrap();
642        let g1_path = tmp.path().join("graph1.json");
643        let g2_path = tmp.path().join("graph2.json");
644        let g = simple_graph();
645        let data = serde_json::to_string(&serde_json::json!({"nodes": g.nodes, "edges": g.edges}))
646            .unwrap();
647        std::fs::write(&g1_path, &data).unwrap();
648        std::fs::write(&g2_path, &data).unwrap();
649        let global_dir = tmp.path().join(".codesynapse");
650        global_add(&g1_path, "repoA", &global_dir).unwrap();
651        global_add(&g2_path, "repoB", &global_dir).unwrap();
652        let global = load_global_graph(&global_dir).unwrap();
653        assert!(global.nodes.iter().any(|n| n.id == "repoA::userservice"));
654        assert!(global.nodes.iter().any(|n| n.id == "repoB::userservice"));
655        assert_eq!(global.nodes.len(), 2);
656    }
657
658    #[test]
659    fn test_global_remove() {
660        let tmp = tempfile::tempdir().unwrap();
661        let src_path = tmp.path().join("graph.json");
662        let g = simple_graph();
663        std::fs::write(
664            &src_path,
665            serde_json::to_string(&serde_json::json!({"nodes": g.nodes, "edges": g.edges}))
666                .unwrap(),
667        )
668        .unwrap();
669        let global_dir = tmp.path().join(".codesynapse");
670        global_add(&src_path, "repoA", &global_dir).unwrap();
671        let removed = global_remove("repoA", &global_dir).unwrap();
672        assert!(removed > 0);
673        let repos = global_list(&global_dir);
674        assert!(!repos.contains_key("repoA"));
675    }
676
677    #[test]
678    fn test_global_remove_unknown_tag_raises() {
679        let tmp = tempfile::tempdir().unwrap();
680        let global_dir = tmp.path().join(".codesynapse");
681        let result = global_remove("nonexistent", &global_dir);
682        assert!(result.is_err());
683    }
684
685    #[test]
686    fn test_global_add_collision_warning() {
687        let tmp = tempfile::tempdir().unwrap();
688        let g1 = tmp.path().join("graph1.json");
689        let g2 = tmp.path().join("graph2.json");
690        let g = GraphData {
691            nodes: vec![make_node("x", "X", "x.py")],
692            edges: vec![],
693            hyperedges: None,
694        };
695        let data1 = serde_json::to_string(&serde_json::json!({"nodes": g.nodes, "edges": g.edges}))
696            .unwrap();
697        std::fs::write(&g1, &data1).unwrap();
698        std::fs::write(&g2, &data1).unwrap();
699        let global_dir = tmp.path().join(".codesynapse");
700        global_add(&g1, "myrepo", &global_dir).unwrap();
701        let result = global_add(&g2, "myrepo", &global_dir);
702        assert!(result.is_ok());
703    }
704
705    #[test]
706    fn test_dedup_raises_on_cross_repo_nodes() {
707        let nodes = vec![
708            make_node_with_repo("repoA::userservice", "UserService", "repoA"),
709            make_node_with_repo("repoB::userservice", "UserService", "repoB"),
710        ];
711        let result = check_cross_repo_guard(&nodes);
712        assert!(result.is_err());
713        assert!(result.unwrap_err().to_string().contains("multiple repos"));
714    }
715
716    #[test]
717    fn test_dedup_ok_with_single_repo() {
718        let nodes = vec![
719            make_node_with_repo("repoA::userservice", "UserService", "repoA"),
720            make_node_with_repo("repoA::auth", "Auth", "repoA"),
721        ];
722        assert!(check_cross_repo_guard(&nodes).is_ok());
723    }
724
725    #[test]
726    fn test_dedup_ok_with_no_repo_attr() {
727        let nodes = vec![
728            make_node("userservice", "UserService", "src.py"),
729            make_node("auth", "Auth", "src.py"),
730        ];
731        assert!(check_cross_repo_guard(&nodes).is_ok());
732    }
733
734    #[test]
735    fn test_merge_graphs_prefixes_ids() {
736        let g1 = GraphData {
737            nodes: vec![make_node("userservice", "UserService", "src/user.py")],
738            edges: vec![],
739            hyperedges: None,
740        };
741        let g2 = g1.clone();
742        let p1 = prefix_graph(&g1, "repo1");
743        let p2 = prefix_graph(&g2, "repo2");
744        let all_ids: HashSet<&str> = p1
745            .nodes
746            .iter()
747            .chain(p2.nodes.iter())
748            .map(|n| n.id.as_str())
749            .collect();
750        assert!(all_ids.contains("repo1::userservice"));
751        assert!(all_ids.contains("repo2::userservice"));
752        assert_eq!(all_ids.len(), 2);
753    }
754
755    #[test]
756    fn test_global_add_rejects_oversized_source_graph() {
757        let tmp = tempfile::tempdir().unwrap();
758        let src_path = tmp.path().join("graph.json");
759        let g = simple_graph();
760        std::fs::write(
761            &src_path,
762            serde_json::to_string(&serde_json::json!({"nodes": g.nodes, "edges": g.edges}))
763                .unwrap(),
764        )
765        .unwrap();
766        let global_dir = tmp.path().join(".codesynapse");
767        // Normal file should pass (we can't easily set a small cap, so verify normal behavior)
768        let result = global_add(&src_path, "repoA", &global_dir);
769        assert!(result.is_ok());
770    }
771
772    // Fix 2: embed_global_graph returns Ok(0) when model not installed
773    #[test]
774    fn test_embed_global_graph_no_model_returns_zero() {
775        let tmp = tempfile::tempdir().unwrap();
776        let global_dir = tmp.path();
777        // no models/potion-code-16M directory → skip silently
778        let result = embed_global_graph(global_dir);
779        assert!(result.is_ok());
780        assert_eq!(result.unwrap(), 0);
781        assert!(!global_dir.join("embeddings.json").exists());
782    }
783
784    // Fix 2: embed_global_graph returns Ok(0) when graph is empty
785    #[test]
786    fn test_embed_global_graph_empty_graph_returns_zero() {
787        let tmp = tempfile::tempdir().unwrap();
788        let global_dir = tmp.path();
789        // create fake model dir to pass model check
790        std::fs::create_dir_all(global_dir.join("models").join("potion-code-16M")).unwrap();
791        // write an empty global graph
792        std::fs::write(
793            global_dir.join("global-graph.json"),
794            r#"{"nodes":[],"edges":[]}"#,
795        )
796        .unwrap();
797        let result = embed_global_graph(global_dir);
798        assert!(result.is_ok());
799        assert_eq!(result.unwrap(), 0);
800    }
801
802    // Fix 2: mtime gate — embeddings.json newer than global-graph.json → skip
803    #[test]
804    fn test_embed_global_graph_mtime_gate_skips_when_current() {
805        let tmp = tempfile::tempdir().unwrap();
806        let global_dir = tmp.path();
807        std::fs::create_dir_all(global_dir.join("models").join("potion-code-16M")).unwrap();
808
809        let graph_path = global_dir.join("global-graph.json");
810        std::fs::write(&graph_path, r#"{"nodes":[{"id":"a","label":"A","file_type":"code","source_file":"a.rs"}],"edges":[]}"#).unwrap();
811
812        // sleep to ensure embeddings.json gets a strictly later mtime than graph
813        std::thread::sleep(std::time::Duration::from_millis(20));
814        let embed_path = global_dir.join("embeddings.json");
815        std::fs::write(&embed_path, r#"{"sentinel":true}"#).unwrap();
816
817        let original = std::fs::read_to_string(&embed_path).unwrap();
818        let result = embed_global_graph(global_dir);
819        assert!(result.is_ok());
820        assert_eq!(result.unwrap(), 0, "mtime gate should skip re-embedding");
821        assert_eq!(
822            std::fs::read_to_string(&embed_path).unwrap(),
823            original,
824            "embeddings.json should not be rewritten when up-to-date"
825        );
826    }
827}