Skip to main content

codesynapse_core/
build.rs

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        // Remove duplicate edges that may have been created by rewiring
153        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        // Last write wins
462        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    // --- Edge case tests ---
780
781    #[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}