1use crate::error::Result;
2use crate::graph::GraphStore;
3use crate::types::{Edge, Node};
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7pub fn normalize_id(id: &str) -> String {
8 id.to_lowercase()
9 .split(|c: char| !c.is_alphanumeric())
10 .filter(|s| !s.is_empty())
11 .collect::<Vec<_>>()
12 .join("_")
13}
14
15pub struct GraphBuilder {
16 store: Box<dyn GraphStore>,
17 directed: bool,
18}
19
20impl GraphBuilder {
21 pub fn new(store: Box<dyn GraphStore>) -> Self {
22 GraphBuilder {
23 store,
24 directed: false,
25 }
26 }
27
28 pub fn new_directed(store: Box<dyn GraphStore>) -> Self {
29 GraphBuilder {
30 store,
31 directed: true,
32 }
33 }
34
35 pub fn store(&self) -> &dyn GraphStore {
36 self.store.as_ref()
37 }
38
39 pub fn is_directed(&self) -> bool {
40 self.directed
41 }
42
43 pub fn set_directed(&mut self, directed: bool) {
44 self.directed = directed;
45 }
46
47 pub fn add_nodes(&self, nodes: Vec<Node>) -> Result<()> {
48 for node in nodes {
49 self.store.add_node(node)?;
50 }
51 Ok(())
52 }
53
54 pub fn add_edges(&self, edges: Vec<Edge>) -> Result<()> {
55 for edge in edges {
56 self.store.add_edge(edge)?;
57 }
58 Ok(())
59 }
60
61 pub fn build_from_fragments(
62 &self,
63 fragments: Vec<(String, Vec<Node>, Vec<Edge>)>,
64 ) -> Result<()> {
65 for (_source, nodes, edges) in fragments {
66 self.add_nodes(nodes)?;
67 self.add_edges(edges)?;
68 }
69 Ok(())
70 }
71
72 pub fn merge(&self, new_nodes: Vec<Node>, new_edges: Vec<Edge>) -> Result<()> {
73 self.add_nodes(new_nodes)?;
74 self.add_edges(new_edges)?;
75 Ok(())
76 }
77
78 pub fn merge_with_prune(
79 &self,
80 new_nodes: Vec<Node>,
81 new_edges: Vec<Edge>,
82 prune_sources: &[String],
83 ) -> Result<(usize, usize)> {
84 let all_nodes = self.store.get_all_nodes()?;
85 let all_edges = self.store.get_all_edges()?;
86
87 let prune_set: HashSet<&str> = prune_sources.iter().map(|s| s.as_str()).collect();
88 let mut removed_nodes = 0usize;
89 let mut removed_edges = 0usize;
90
91 for node in &all_nodes {
92 if prune_set.contains(node.source_file.as_str()) {
93 self.store.remove_node(&node.id)?;
94 removed_nodes += 1;
95 }
96 }
97
98 for edge in &all_edges {
99 if prune_set.contains(edge.source_file.as_deref().unwrap_or("")) {
100 self.store
101 .remove_edge(&edge.source, &edge.target, &edge.relation)?;
102 removed_edges += 1;
103 }
104 }
105
106 self.add_nodes(new_nodes)?;
107 self.add_edges(new_edges)?;
108
109 Ok((removed_nodes, removed_edges))
110 }
111
112 pub fn label_dedup(&self) -> Result<usize> {
113 let nodes = self.store.get_all_nodes()?;
114 let edges = self.store.get_all_edges()?;
115
116 let mut label_to_ids: HashMap<&str, Vec<&str>> = HashMap::new();
117 for node in &nodes {
118 label_to_ids
119 .entry(node.label.as_str())
120 .or_default()
121 .push(node.id.as_str());
122 }
123
124 let mut removed = 0usize;
125 for ids in label_to_ids.values() {
126 if ids.len() < 2 {
127 continue;
128 }
129 let keep_id = ids[0];
130 for dup_id in &ids[1..] {
131 for edge in &edges {
132 if edge.source == **dup_id {
133 let new_edge = Edge {
134 source: keep_id.to_string(),
135 ..edge.clone()
136 };
137 self.store.add_edge(new_edge)?;
138 }
139 if edge.target == **dup_id {
140 let new_edge = Edge {
141 target: keep_id.to_string(),
142 ..edge.clone()
143 };
144 self.store.add_edge(new_edge)?;
145 }
146 }
147 self.store.remove_node(dup_id)?;
148 removed += 1;
149 }
150 }
151
152 if removed > 0 {
154 self.remove_duplicate_edges()?;
155 }
156
157 Ok(removed)
158 }
159
160 fn remove_duplicate_edges(&self) -> Result<()> {
161 let edges = self.store.get_all_edges()?;
162 let mut seen: HashSet<(String, String, String)> = HashSet::new();
163 for edge in &edges {
164 let key = (
165 edge.source.clone(),
166 edge.target.clone(),
167 edge.relation.clone(),
168 );
169 if !seen.insert(key) {
170 self.store
171 .remove_edge(&edge.source, &edge.target, &edge.relation)?;
172 }
173 }
174 Ok(())
175 }
176
177 pub fn lang_filter_inferred_calls(&self) -> Result<usize> {
178 let all_nodes = self.store.get_all_nodes()?;
179 let edges = self.store.get_all_edges()?;
180 let node_map: HashMap<&str, &Node> = all_nodes.iter().map(|n| (n.id.as_str(), n)).collect();
181
182 let mut removed = 0usize;
183 for edge in &edges {
184 if edge.confidence == "INFERRED" {
185 if let (Some(src_node), Some(tgt_node)) = (
186 node_map.get(edge.source.as_str()),
187 node_map.get(edge.target.as_str()),
188 ) {
189 if src_node.file_type != tgt_node.file_type
190 || Self::lang_from_source(&src_node.source_file)
191 != Self::lang_from_source(&tgt_node.source_file)
192 {
193 self.store
194 .remove_edge(&edge.source, &edge.target, &edge.relation)?;
195 removed += 1;
196 }
197 }
198 }
199 }
200 Ok(removed)
201 }
202
203 fn lang_from_source(source_file: &str) -> &'static str {
204 let ext = Path::new(source_file)
205 .extension()
206 .and_then(|e| e.to_str())
207 .unwrap_or("");
208 match ext {
209 "py" => "python",
210 "js" | "jsx" => "javascript",
211 "ts" | "tsx" => "typescript",
212 "rs" => "rust",
213 "go" => "go",
214 "java" => "java",
215 "c" | "h" => "c",
216 "cpp" | "hpp" | "cc" | "cxx" => "cpp",
217 "cs" => "csharp",
218 "rb" => "ruby",
219 "php" => "php",
220 "swift" => "swift",
221 "kt" | "kts" => "kotlin",
222 _ => "unknown",
223 }
224 }
225
226 pub fn normalize_source_file(&self, root: &Path) -> Result<()> {
227 let nodes = self.store.get_all_nodes()?;
228 for node in nodes {
229 if let Ok(relative) = Path::new(&node.source_file).strip_prefix(root) {
230 let relative_str = relative.to_string_lossy().to_string();
231 let mut updated = node.clone();
232 updated.source_file = relative_str;
233 self.store.add_node(updated)?;
234 }
235 }
236 Ok(())
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::graph::GraphStore;
244 use crate::NodeId;
245 use std::collections::HashMap;
246 use std::sync::Mutex;
247
248 struct MockStore {
249 nodes: Mutex<HashMap<NodeId, Node>>,
250 edges: Mutex<Vec<Edge>>,
251 }
252
253 impl MockStore {
254 fn new() -> Self {
255 MockStore {
256 nodes: Mutex::new(HashMap::new()),
257 edges: Mutex::new(Vec::new()),
258 }
259 }
260 }
261
262 impl GraphStore for MockStore {
263 fn dijkstra_shortest_path(&self, _src: &str, _tgt: &str) -> Result<Option<Vec<Node>>> {
264 Ok(None)
265 }
266
267 fn add_node(&self, node: Node) -> Result<()> {
268 self.nodes.lock().unwrap().insert(node.id.clone(), node);
269 Ok(())
270 }
271
272 fn add_edge(&self, edge: Edge) -> Result<()> {
273 self.edges.lock().unwrap().push(edge);
274 Ok(())
275 }
276
277 fn get_node(&self, id: &str) -> Result<Option<Node>> {
278 Ok(self.nodes.lock().unwrap().get(id).cloned())
279 }
280
281 fn get_all_nodes(&self) -> Result<Vec<Node>> {
282 Ok(self.nodes.lock().unwrap().values().cloned().collect())
283 }
284
285 fn get_all_edges(&self) -> Result<Vec<Edge>> {
286 Ok(self.edges.lock().unwrap().clone())
287 }
288
289 fn neighbors(&self, id: &str, _filter: Option<&str>) -> Result<Vec<(Node, Edge)>> {
290 let edges = self.edges.lock().unwrap();
291 let nodes = self.nodes.lock().unwrap();
292 let mut result = Vec::new();
293 for edge in edges.iter() {
294 if edge.source == id {
295 if let Some(node) = nodes.get(&edge.target) {
296 result.push((node.clone(), edge.clone()));
297 }
298 } else if edge.target == id {
299 if let Some(node) = nodes.get(&edge.source) {
300 result.push((node.clone(), edge.clone()));
301 }
302 }
303 }
304 Ok(result)
305 }
306
307 fn search(&self, query: &str, _top_k: usize) -> Result<Vec<(f64, Node)>> {
308 let nodes = self.nodes.lock().unwrap();
309 let q = query.to_lowercase();
310 let mut results = Vec::new();
311 for node in nodes.values() {
312 if node.label.to_lowercase().contains(&q) || node.id.to_lowercase().contains(&q) {
313 results.push((1.0, node.clone()));
314 }
315 }
316 Ok(results)
317 }
318
319 fn shortest_path(&self, _src: &str, _tgt: &str) -> Result<Option<Vec<Node>>> {
320 Ok(None)
321 }
322
323 fn subgraph(&self, node_ids: &[&str]) -> Result<(Vec<Node>, Vec<Edge>)> {
324 let nodes = self.nodes.lock().unwrap();
325 let edges = self.edges.lock().unwrap();
326 let id_set: HashSet<&str> = node_ids.iter().copied().collect();
327
328 let sg_nodes: Vec<Node> = nodes
329 .values()
330 .filter(|n| id_set.contains(n.id.as_str()))
331 .cloned()
332 .collect();
333 let sg_edges: Vec<Edge> = edges
334 .iter()
335 .filter(|e| {
336 id_set.contains(e.source.as_str()) && id_set.contains(e.target.as_str())
337 })
338 .cloned()
339 .collect();
340
341 Ok((sg_nodes, sg_edges))
342 }
343
344 fn node_count(&self) -> Result<usize> {
345 Ok(self.nodes.lock().unwrap().len())
346 }
347
348 fn edge_count(&self) -> Result<usize> {
349 Ok(self.edges.lock().unwrap().len())
350 }
351
352 fn remove_node(&self, id: &str) -> Result<()> {
353 self.nodes.lock().unwrap().remove(id);
354 Ok(())
355 }
356
357 fn remove_edge(&self, source: &str, target: &str, relation: &str) -> Result<()> {
358 let mut edges = self.edges.lock().unwrap();
359 edges.retain(|e| !(e.source == source && e.target == target && e.relation == relation));
360 Ok(())
361 }
362
363 fn clear(&self) -> Result<()> {
364 self.nodes.lock().unwrap().clear();
365 self.edges.lock().unwrap().clear();
366 Ok(())
367 }
368 }
369
370 #[test]
371 fn test_build_empty() {
372 let store = MockStore::new();
373 let builder = GraphBuilder::new(Box::new(store));
374 let nodes = vec![];
375 let edges = vec![];
376 builder
377 .build_from_fragments(vec![("test".to_string(), nodes, edges)])
378 .unwrap();
379 assert_eq!(builder.store().node_count().unwrap(), 0);
380 assert_eq!(builder.store().edge_count().unwrap(), 0);
381 }
382
383 #[test]
384 fn test_build_single_node() {
385 let store = MockStore::new();
386 let builder = GraphBuilder::new(Box::new(store));
387 let node = Node {
388 id: "a".to_string(),
389 label: "A".to_string(),
390 file_type: "code".to_string(),
391 source_file: "test.py".to_string(),
392 source_location: None,
393 community: None,
394 rationale: None,
395 docstring: None,
396 metadata: HashMap::new(),
397 };
398 builder.add_nodes(vec![node]).unwrap();
399 assert_eq!(builder.store().node_count().unwrap(), 1);
400 assert_eq!(builder.store().edge_count().unwrap(), 0);
401 }
402
403 #[test]
404 fn test_build_node_edge() {
405 let store = MockStore::new();
406 let builder = GraphBuilder::new(Box::new(store));
407 let node = Node {
408 id: "a".to_string(),
409 label: "A".to_string(),
410 file_type: "code".to_string(),
411 source_file: "test.py".to_string(),
412 source_location: None,
413 community: None,
414 rationale: None,
415 docstring: None,
416 metadata: HashMap::new(),
417 };
418 let edge = Edge {
419 source: "a".to_string(),
420 target: "b".to_string(),
421 relation: "imports".to_string(),
422 confidence: "EXTRACTED".to_string(),
423 source_file: Some("test.py".to_string()),
424 weight: 1.0,
425 context: None,
426 };
427 builder.add_nodes(vec![node]).unwrap();
428 builder.add_edges(vec![edge]).unwrap();
429 assert_eq!(builder.store().node_count().unwrap(), 1);
430 assert_eq!(builder.store().edge_count().unwrap(), 1);
431 }
432
433 #[test]
434 fn test_build_duplicate_node() {
435 let store = MockStore::new();
436 let builder = GraphBuilder::new(Box::new(store));
437 let node1 = Node {
438 id: "a".to_string(),
439 label: "A".to_string(),
440 file_type: "code".to_string(),
441 source_file: "test.py".to_string(),
442 source_location: None,
443 community: None,
444 rationale: None,
445 docstring: None,
446 metadata: HashMap::new(),
447 };
448 let node2 = Node {
449 id: "a".to_string(),
450 label: "B".to_string(),
451 file_type: "code".to_string(),
452 source_file: "test.py".to_string(),
453 source_location: None,
454 community: None,
455 rationale: None,
456 docstring: None,
457 metadata: HashMap::new(),
458 };
459 builder.add_nodes(vec![node1]).unwrap();
460 builder.add_nodes(vec![node2]).unwrap();
461 let node = builder.store().get_node("a").unwrap().unwrap();
463 assert_eq!(node.label, "B");
464 }
465
466 #[test]
467 fn test_build_duplicate_edge() {
468 let store = MockStore::new();
469 let builder = GraphBuilder::new(Box::new(store));
470 let edge1 = Edge {
471 source: "a".to_string(),
472 target: "b".to_string(),
473 relation: "imports".to_string(),
474 confidence: "EXTRACTED".to_string(),
475 source_file: Some("test.py".to_string()),
476 weight: 1.0,
477 context: None,
478 };
479 let edge2 = Edge {
480 source: "a".to_string(),
481 target: "b".to_string(),
482 relation: "calls".to_string(),
483 confidence: "EXTRACTED".to_string(),
484 source_file: Some("test.py".to_string()),
485 weight: 1.0,
486 context: None,
487 };
488 builder.add_edges(vec![edge1, edge2]).unwrap();
489 assert_eq!(builder.store().edge_count().unwrap(), 2);
490 }
491
492 #[test]
493 fn test_build_directed() {
494 let store = MockStore::new();
495 let builder = GraphBuilder::new_directed(Box::new(store));
496 assert!(builder.is_directed());
497
498 let edge = Edge {
499 source: "a".to_string(),
500 target: "b".to_string(),
501 relation: "imports".to_string(),
502 confidence: "EXTRACTED".to_string(),
503 source_file: Some("test.py".to_string()),
504 weight: 1.0,
505 context: None,
506 };
507 builder.add_edges(vec![edge.clone()]).unwrap();
508 let stored_edges = builder.store().get_all_edges().unwrap();
509 assert_eq!(stored_edges.len(), 1);
510 assert_eq!(stored_edges[0].source, "a");
511 assert_eq!(stored_edges[0].target, "b");
512 }
513
514 #[test]
515 fn test_build_id_mismatch() {
516 let id1 = normalize_id("session_validatetoken");
517 let id2 = normalize_id("Session_ValidateToken");
518 assert_eq!(id1, id2, "normalized IDs should match");
519
520 let id3 = normalize_id("MY_Class__Name");
521 let id4 = normalize_id("my-class-name");
522 assert_eq!(id3, id4);
523
524 let store = MockStore::new();
525 let builder = GraphBuilder::new(Box::new(store));
526 let node1 = Node {
527 id: normalize_id("session_validatetoken"),
528 label: "validateToken".to_string(),
529 file_type: "code".to_string(),
530 source_file: "test.py".to_string(),
531 source_location: None,
532 community: None,
533 rationale: None,
534 docstring: None,
535 metadata: HashMap::new(),
536 };
537 let node2 = Node {
538 id: normalize_id("Session-ValidateToken"),
539 label: "validateToken".to_string(),
540 file_type: "code".to_string(),
541 source_file: "test.py".to_string(),
542 source_location: None,
543 community: None,
544 rationale: None,
545 docstring: None,
546 metadata: HashMap::new(),
547 };
548 builder.add_nodes(vec![node1, node2]).unwrap();
549 assert_eq!(builder.store().node_count().unwrap(), 1);
550 }
551
552 #[test]
553 fn test_build_merge_basic() {
554 let store = MockStore::new();
555 let builder = GraphBuilder::new(Box::new(store));
556 let node_a = Node {
557 id: "a".to_string(),
558 label: "A".to_string(),
559 file_type: "code".to_string(),
560 source_file: "test.py".to_string(),
561 source_location: None,
562 community: None,
563 rationale: None,
564 docstring: None,
565 metadata: HashMap::new(),
566 };
567 builder.merge(vec![node_a], vec![]).unwrap();
568 assert_eq!(builder.store().node_count().unwrap(), 1);
569
570 let node_b = Node {
571 id: "b".to_string(),
572 label: "B".to_string(),
573 file_type: "code".to_string(),
574 source_file: "test.py".to_string(),
575 source_location: None,
576 community: None,
577 rationale: None,
578 docstring: None,
579 metadata: HashMap::new(),
580 };
581 builder.merge(vec![node_b], vec![]).unwrap();
582 assert_eq!(builder.store().node_count().unwrap(), 2);
583 }
584
585 #[test]
586 fn test_build_merge_incremental() {
587 let store = MockStore::new();
588 let builder = GraphBuilder::new(Box::new(store));
589 let node_a = Node {
590 id: "a".to_string(),
591 label: "A".to_string(),
592 file_type: "code".to_string(),
593 source_file: "test.py".to_string(),
594 source_location: None,
595 community: None,
596 rationale: None,
597 docstring: None,
598 metadata: HashMap::new(),
599 };
600 builder.merge(vec![node_a], vec![]).unwrap();
601
602 let node_b = Node {
603 id: "b".to_string(),
604 label: "B".to_string(),
605 file_type: "code".to_string(),
606 source_file: "test.py".to_string(),
607 source_location: None,
608 community: None,
609 rationale: None,
610 docstring: None,
611 metadata: HashMap::new(),
612 };
613 let edge_ab = Edge {
614 source: "a".to_string(),
615 target: "b".to_string(),
616 relation: "calls".to_string(),
617 confidence: "EXTRACTED".to_string(),
618 source_file: Some("test.py".to_string()),
619 weight: 1.0,
620 context: None,
621 };
622 builder.merge(vec![node_b], vec![edge_ab]).unwrap();
623 assert_eq!(builder.store().node_count().unwrap(), 2);
624 assert_eq!(builder.store().edge_count().unwrap(), 1);
625 }
626
627 #[test]
628 fn test_build_merge_prune() {
629 let store = MockStore::new();
630 let builder = GraphBuilder::new(Box::new(store));
631 let node_a = Node {
632 id: "a".to_string(),
633 label: "A".to_string(),
634 file_type: "code".to_string(),
635 source_file: "keep.py".to_string(),
636 source_location: None,
637 community: None,
638 rationale: None,
639 docstring: None,
640 metadata: HashMap::new(),
641 };
642 let node_b = Node {
643 id: "b".to_string(),
644 label: "B".to_string(),
645 file_type: "code".to_string(),
646 source_file: "delete.py".to_string(),
647 source_location: None,
648 community: None,
649 rationale: None,
650 docstring: None,
651 metadata: HashMap::new(),
652 };
653 builder.add_nodes(vec![node_a, node_b]).unwrap();
654 assert_eq!(builder.store().node_count().unwrap(), 2);
655
656 let (removed_nodes, removed_edges) = builder
657 .merge_with_prune(vec![], vec![], &["delete.py".to_string()])
658 .unwrap();
659 assert_eq!(removed_nodes, 1);
660 assert_eq!(removed_edges, 0);
661 assert_eq!(builder.store().node_count().unwrap(), 1);
662 assert!(builder.store().get_node("a").unwrap().is_some());
663 assert!(builder.store().get_node("b").unwrap().is_none());
664 }
665
666 #[test]
667 fn test_build_label_dedup() {
668 let store = MockStore::new();
669 let builder = GraphBuilder::new(Box::new(store));
670 let node_a = Node {
671 id: "dup_a".to_string(),
672 label: "CommonLabel".to_string(),
673 file_type: "code".to_string(),
674 source_file: "test.py".to_string(),
675 source_location: None,
676 community: None,
677 rationale: None,
678 docstring: None,
679 metadata: HashMap::new(),
680 };
681 let node_b = Node {
682 id: "dup_b".to_string(),
683 label: "CommonLabel".to_string(),
684 file_type: "code".to_string(),
685 source_file: "test.py".to_string(),
686 source_location: None,
687 community: None,
688 rationale: None,
689 docstring: None,
690 metadata: HashMap::new(),
691 };
692 let edge = Edge {
693 source: "dup_a".to_string(),
694 target: "dup_b".to_string(),
695 relation: "calls".to_string(),
696 confidence: "EXTRACTED".to_string(),
697 source_file: Some("test.py".to_string()),
698 weight: 1.0,
699 context: None,
700 };
701 builder.add_nodes(vec![node_a, node_b]).unwrap();
702 builder.add_edges(vec![edge]).unwrap();
703 assert_eq!(builder.store().node_count().unwrap(), 2);
704
705 let removed = builder.label_dedup().unwrap();
706 assert_eq!(removed, 1);
707 assert_eq!(builder.store().node_count().unwrap(), 1);
708 }
709
710 #[test]
711 fn test_build_lang_filter_inferred_calls() {
712 let store = MockStore::new();
713 let builder = GraphBuilder::new(Box::new(store));
714 let py_node = Node {
715 id: "py_func".to_string(),
716 label: "py_func".to_string(),
717 file_type: "code".to_string(),
718 source_file: "app.py".to_string(),
719 source_location: None,
720 community: None,
721 rationale: None,
722 docstring: None,
723 metadata: HashMap::new(),
724 };
725 let ts_node = Node {
726 id: "ts_func".to_string(),
727 label: "ts_func".to_string(),
728 file_type: "code".to_string(),
729 source_file: "app.ts".to_string(),
730 source_location: None,
731 community: None,
732 rationale: None,
733 docstring: None,
734 metadata: HashMap::new(),
735 };
736 let cross_edge = Edge {
737 source: "py_func".to_string(),
738 target: "ts_func".to_string(),
739 relation: "calls".to_string(),
740 confidence: "INFERRED".to_string(),
741 source_file: Some("app.py".to_string()),
742 weight: 1.0,
743 context: None,
744 };
745 builder.add_nodes(vec![py_node, ts_node]).unwrap();
746 builder.add_edges(vec![cross_edge]).unwrap();
747 assert_eq!(builder.store().edge_count().unwrap(), 1);
748
749 let removed = builder.lang_filter_inferred_calls().unwrap();
750 assert_eq!(removed, 1);
751 assert_eq!(builder.store().edge_count().unwrap(), 0);
752 }
753
754 #[test]
755 fn test_build_normalize_source_file() {
756 let store = MockStore::new();
757 let builder = GraphBuilder::new(Box::new(store));
758 let root = std::env::temp_dir().join("codesynapse_test_normalize");
759 let abs_path = root.join("src/main.py").to_string_lossy().to_string();
760
761 let node = Node {
762 id: "main".to_string(),
763 label: "Main".to_string(),
764 file_type: "code".to_string(),
765 source_file: abs_path.clone(),
766 source_location: None,
767 community: None,
768 rationale: None,
769 docstring: None,
770 metadata: HashMap::new(),
771 };
772 builder.add_nodes(vec![node]).unwrap();
773 builder.normalize_source_file(&root).unwrap();
774
775 let stored = builder.store().get_node("main").unwrap().unwrap();
776 assert_eq!(stored.source_file, "src/main.py");
777 }
778
779 #[test]
782 fn test_build_empty_fragments_vec() {
783 let store = MockStore::new();
784 let builder = GraphBuilder::new(Box::new(store));
785 builder.build_from_fragments(vec![]).unwrap();
786 assert_eq!(builder.store().node_count().unwrap(), 0);
787 }
788
789 #[test]
790 fn test_build_self_loop_edge() {
791 let store = MockStore::new();
792 let builder = GraphBuilder::new(Box::new(store));
793 let node = Node {
794 id: "a".to_string(),
795 label: "A".to_string(),
796 file_type: "code".to_string(),
797 source_file: "test.py".to_string(),
798 source_location: None,
799 community: None,
800 rationale: None,
801 docstring: None,
802 metadata: HashMap::new(),
803 };
804 let edge = Edge {
805 source: "a".to_string(),
806 target: "a".to_string(),
807 relation: "self_ref".to_string(),
808 confidence: "EXTRACTED".to_string(),
809 source_file: Some("test.py".to_string()),
810 weight: 1.0,
811 context: None,
812 };
813 builder.add_nodes(vec![node]).unwrap();
814 builder.add_edges(vec![edge]).unwrap();
815 assert_eq!(builder.store().edge_count().unwrap(), 1);
816 }
817
818 #[test]
819 fn test_build_edge_missing_node() {
820 let store = MockStore::new();
821 let builder = GraphBuilder::new(Box::new(store));
822 let edge = Edge {
823 source: "missing_src".to_string(),
824 target: "missing_tgt".to_string(),
825 relation: "calls".to_string(),
826 confidence: "EXTRACTED".to_string(),
827 source_file: Some("test.py".to_string()),
828 weight: 1.0,
829 context: None,
830 };
831 builder.add_edges(vec![edge]).unwrap();
832 assert_eq!(builder.store().edge_count().unwrap(), 1);
833 assert_eq!(builder.store().node_count().unwrap(), 0);
834 }
835
836 #[test]
837 fn test_build_label_dedup_no_match() {
838 let store = MockStore::new();
839 let builder = GraphBuilder::new(Box::new(store));
840 let node_a = Node {
841 id: "a".to_string(),
842 label: "Alpha".to_string(),
843 file_type: "code".to_string(),
844 source_file: "f1.py".to_string(),
845 source_location: None,
846 community: None,
847 rationale: None,
848 docstring: None,
849 metadata: HashMap::new(),
850 };
851 let node_b = Node {
852 id: "b".to_string(),
853 label: "Beta".to_string(),
854 file_type: "code".to_string(),
855 source_file: "f2.py".to_string(),
856 source_location: None,
857 community: None,
858 rationale: None,
859 docstring: None,
860 metadata: HashMap::new(),
861 };
862 builder.add_nodes(vec![node_a, node_b]).unwrap();
863 let removed = builder.label_dedup().unwrap();
864 assert_eq!(removed, 0, "no dedup when all labels are unique");
865 assert_eq!(builder.store().node_count().unwrap(), 2);
866 }
867
868 #[test]
869 fn test_build_merge_with_prune_nonexistent_source() {
870 let store = MockStore::new();
871 let builder = GraphBuilder::new(Box::new(store));
872 builder
873 .add_nodes(vec![Node {
874 id: "a".to_string(),
875 label: "A".to_string(),
876 file_type: "code".to_string(),
877 source_file: "real.py".to_string(),
878 source_location: None,
879 community: None,
880 rationale: None,
881 docstring: None,
882 metadata: HashMap::new(),
883 }])
884 .unwrap();
885 let (removed_nodes, removed_edges) = builder
886 .merge_with_prune(vec![], vec![], &["nonexistent.py".to_string()])
887 .unwrap();
888 assert_eq!(removed_nodes, 0, "no-op on nonexistent source");
889 assert_eq!(removed_edges, 0);
890 assert_eq!(builder.store().node_count().unwrap(), 1);
891 }
892
893 #[test]
894 fn test_build_lang_filter_inferred_empty() {
895 let store = MockStore::new();
896 let builder = GraphBuilder::new(Box::new(store));
897 let removed = builder.lang_filter_inferred_calls().unwrap();
898 assert_eq!(removed, 0, "no edges to remove on empty graph");
899 }
900
901 #[test]
902 fn test_build_multiple_fragments_same_nodes() {
903 let store = MockStore::new();
904 let builder = GraphBuilder::new(Box::new(store));
905 let node = Node {
906 id: "shared".to_string(),
907 label: "Shared".to_string(),
908 file_type: "code".to_string(),
909 source_file: "a.py".to_string(),
910 source_location: None,
911 community: None,
912 rationale: None,
913 docstring: None,
914 metadata: HashMap::new(),
915 };
916 builder
917 .build_from_fragments(vec![
918 ("a.py".to_string(), vec![node.clone()], vec![]),
919 ("b.py".to_string(), vec![node], vec![]),
920 ])
921 .unwrap();
922 assert_eq!(builder.store().node_count().unwrap(), 1);
923 }
924}