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
370pub 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 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 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 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 let result = global_add(&src_path, "repoA", &global_dir);
769 assert!(result.is_ok());
770 }
771
772 #[test]
774 fn test_embed_global_graph_no_model_returns_zero() {
775 let tmp = tempfile::tempdir().unwrap();
776 let global_dir = tmp.path();
777 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 #[test]
786 fn test_embed_global_graph_empty_graph_returns_zero() {
787 let tmp = tempfile::tempdir().unwrap();
788 let global_dir = tmp.path();
789 std::fs::create_dir_all(global_dir.join("models").join("potion-code-16M")).unwrap();
791 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 #[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 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}