Skip to main content

codesynapse_core/
scip_ingest.rs

1use serde_json::Value;
2use sha2::{Digest, Sha256};
3use std::collections::{HashMap, HashSet};
4
5use crate::types::{Edge, ExtractionFragment, Node};
6
7pub fn ingest_scip_json(doc: &Value, source_file: &str, language: &str) -> ExtractionFragment {
8    let mut nodes: Vec<Node> = Vec::new();
9    let mut edges: Vec<Edge> = Vec::new();
10    let mut seen_node_ids: HashSet<String> = HashSet::new();
11    let mut seen_edges: HashSet<(String, String, String, String)> = HashSet::new();
12
13    let obj = match doc.as_object() {
14        Some(o) => o,
15        None => return ExtractionFragment { nodes, edges },
16    };
17
18    let documents = match obj.get("documents").and_then(|v| v.as_array()) {
19        Some(d) => d,
20        None => return ExtractionFragment { nodes, edges },
21    };
22
23    // Pass 1: build symbol → node_id indices
24    let mut per_doc_index: HashMap<(String, String), String> = HashMap::new();
25    let mut global_index: HashMap<String, Vec<String>> = HashMap::new();
26    let mut symbol_records: Vec<SymbolRecord> = Vec::new();
27
28    for document in documents {
29        let document = match document.as_object() {
30            Some(d) => d,
31            None => continue,
32        };
33        let doc_path = coerce_str(document.get("relative_path"), source_file);
34        let doc_language = coerce_str(document.get("language"), language);
35        let symbols = match document.get("symbols").and_then(|v| v.as_array()) {
36            Some(s) => s,
37            None => continue,
38        };
39        for symbol in symbols {
40            let symbol_obj = match symbol.as_object() {
41                Some(s) => s,
42                None => continue,
43            };
44            let symbol_id = coerce_str(symbol_obj.get("symbol"), "");
45            if symbol_id.is_empty() {
46                continue;
47            }
48            let node_id = make_scip_node_id(symbol_id, doc_path);
49            per_doc_index
50                .entry((symbol_id.to_string(), doc_path.to_string()))
51                .or_insert_with(|| node_id.clone());
52            let candidates = global_index.entry(symbol_id.to_string()).or_default();
53            if !candidates.contains(&node_id) {
54                candidates.push(node_id.clone());
55            }
56            symbol_records.push(SymbolRecord {
57                node_id,
58                symbol_id: symbol_id.to_string(),
59                doc_path: doc_path.to_string(),
60                language: doc_language.to_string(),
61                raw: symbol.clone(),
62            });
63        }
64    }
65
66    // Pass 2: emit nodes + relationship edges
67    for record in &symbol_records {
68        emit_symbol_node(record, &mut nodes, &mut seen_node_ids);
69        emit_relationships(
70            record,
71            &per_doc_index,
72            &global_index,
73            &mut nodes,
74            &mut edges,
75            &mut seen_node_ids,
76            &mut seen_edges,
77        );
78    }
79
80    ExtractionFragment { nodes, edges }
81}
82
83struct SymbolRecord {
84    node_id: String,
85    symbol_id: String,
86    doc_path: String,
87    #[allow(dead_code)]
88    language: String,
89    raw: Value,
90}
91
92fn emit_symbol_node(
93    record: &SymbolRecord,
94    nodes: &mut Vec<Node>,
95    seen_node_ids: &mut HashSet<String>,
96) {
97    if seen_node_ids.contains(&record.node_id) {
98        return;
99    }
100    let raw = &record.raw;
101    let kind = coerce_str(raw.get("kind"), "unknown");
102    let display_name = coerce_str(raw.get("display_name"), "");
103    let description = raw
104        .get("documentation")
105        .and_then(|v| v.as_array())
106        .and_then(|arr| arr.first())
107        .and_then(|v| v.as_str())
108        .unwrap_or("")
109        .to_string();
110    let occurrences = raw.get("occurrences").unwrap_or(&Value::Null);
111    let sourceline = first_occurrence_line(occurrences);
112    let suffix = if record.symbol_id.contains('#') {
113        record
114            .symbol_id
115            .rsplit('#')
116            .next()
117            .unwrap_or(&record.symbol_id)
118    } else {
119        &record.symbol_id
120    };
121    let label = if !display_name.is_empty() {
122        display_name.to_string()
123    } else if !suffix.is_empty() {
124        suffix.to_string()
125    } else {
126        record.symbol_id.clone()
127    };
128    seen_node_ids.insert(record.node_id.clone());
129    nodes.push(Node {
130        id: record.node_id.clone(),
131        label,
132        file_type: scip_kind_to_file_type(kind).to_string(),
133        source_file: record.doc_path.clone(),
134        source_location: if sourceline > 0 {
135            Some(format!("L{}", sourceline))
136        } else {
137            None
138        },
139        community: None,
140        rationale: None,
141        docstring: None,
142        metadata: build_scip_metadata(&record.symbol_id, kind, &description),
143    });
144}
145
146fn emit_relationships(
147    record: &SymbolRecord,
148    per_doc_index: &HashMap<(String, String), String>,
149    global_index: &HashMap<String, Vec<String>>,
150    nodes: &mut Vec<Node>,
151    edges: &mut Vec<Edge>,
152    seen_node_ids: &mut HashSet<String>,
153    seen_edges: &mut HashSet<(String, String, String, String)>,
154) {
155    let raw = &record.raw;
156    let occurrences = raw.get("occurrences").unwrap_or(&Value::Null);
157    let sourceline = first_occurrence_line(occurrences);
158    let relationships = match raw.get("relationships").and_then(|v| v.as_array()) {
159        Some(r) => r,
160        None => return,
161    };
162    for rel in relationships {
163        let rel_obj = match rel.as_object() {
164            Some(r) => r,
165            None => continue,
166        };
167        let target_symbol = coerce_str(rel_obj.get("symbol"), "");
168        if target_symbol.is_empty() {
169            continue;
170        }
171        let target_node_id = match resolve_relationship_target(
172            target_symbol,
173            &record.doc_path,
174            per_doc_index,
175            global_index,
176        ) {
177            Some(id) => id,
178            None => {
179                let stub_id = make_scip_node_id(target_symbol, &record.doc_path);
180                if !seen_node_ids.contains(&stub_id) {
181                    seen_node_ids.insert(stub_id.clone());
182                    let suffix = if target_symbol.contains('#') {
183                        target_symbol.rsplit('#').next().unwrap_or(target_symbol)
184                    } else {
185                        target_symbol
186                    };
187                    nodes.push(Node {
188                        id: stub_id.clone(),
189                        label: if suffix.is_empty() {
190                            target_symbol.to_string()
191                        } else {
192                            suffix.to_string()
193                        },
194                        file_type: "code".to_string(),
195                        source_file: record.doc_path.clone(),
196                        source_location: None,
197                        community: None,
198                        rationale: None,
199                        docstring: None,
200                        metadata: build_scip_metadata(target_symbol, "external", ""),
201                    });
202                }
203                stub_id
204            }
205        };
206        let relation = scip_relation_for(rel);
207        let source_location = if sourceline > 0 {
208            format!("L{}", sourceline)
209        } else {
210            String::new()
211        };
212        let key = (
213            record.node_id.clone(),
214            target_node_id.clone(),
215            relation.to_string(),
216            source_location.clone(),
217        );
218        if seen_edges.contains(&key) {
219            continue;
220        }
221        seen_edges.insert(key);
222        edges.push(Edge {
223            source: record.node_id.clone(),
224            target: target_node_id,
225            relation: relation.to_string(),
226            confidence: "EXTRACTED".to_string(),
227            source_file: Some(record.doc_path.clone()),
228            weight: 1.0,
229            context: None,
230        });
231    }
232}
233
234fn resolve_relationship_target(
235    target_symbol: &str,
236    source_doc_path: &str,
237    per_doc_index: &HashMap<(String, String), String>,
238    global_index: &HashMap<String, Vec<String>>,
239) -> Option<String> {
240    if let Some(id) = per_doc_index.get(&(target_symbol.to_string(), source_doc_path.to_string())) {
241        return Some(id.clone());
242    }
243    let candidates = global_index.get(target_symbol)?;
244    if candidates.len() == 1 {
245        return Some(candidates[0].clone());
246    }
247    None
248}
249
250fn is_true(value: Option<&Value>) -> bool {
251    matches!(value, Some(Value::Bool(true)))
252}
253
254fn scip_relation_for(rel: &Value) -> &'static str {
255    let obj = match rel.as_object() {
256        Some(o) => o,
257        None => return "scip_ref",
258    };
259    if is_true(obj.get("is_implementation")) {
260        return "scip_impl";
261    }
262    if is_true(obj.get("is_type_definition")) {
263        return "scip_typed";
264    }
265    if is_true(obj.get("is_definition")) {
266        return "scip_def";
267    }
268    "scip_ref"
269}
270
271fn first_occurrence_line(occurrences: &Value) -> u32 {
272    let arr = match occurrences.as_array() {
273        Some(a) if !a.is_empty() => a,
274        _ => return 0,
275    };
276    let first = match arr[0].as_object() {
277        Some(o) => o,
278        None => return 0,
279    };
280    let range = match first.get("range").and_then(|v| v.as_array()) {
281        Some(r) if !r.is_empty() => r,
282        _ => return 0,
283    };
284    match range[0].as_u64() {
285        Some(line) => line as u32,
286        None => 0,
287    }
288}
289
290fn coerce_str<'a>(value: Option<&'a Value>, default: &'a str) -> &'a str {
291    match value {
292        Some(Value::String(s)) => s.as_str(),
293        _ => default,
294    }
295}
296
297fn make_scip_node_id(symbol: &str, source_file: &str) -> String {
298    let raw = format!("{}:{}", source_file, symbol);
299    let mut hasher = Sha256::new();
300    hasher.update(raw.as_bytes());
301    let hash = hasher.finalize();
302    let hex = format!("{:x}", hash);
303    let h = &hex[..12];
304    let suffix: String = symbol
305        .rsplit('#')
306        .next()
307        .unwrap_or(symbol)
308        .chars()
309        .map(|c| {
310            if c.is_alphanumeric() || c == '_' {
311                c
312            } else {
313                '_'
314            }
315        })
316        .collect::<String>()
317        .trim_matches('_')
318        .to_lowercase()
319        .chars()
320        .collect();
321    if suffix.is_empty() {
322        format!("scip_{}", h)
323    } else {
324        format!("scip_{}_{}", suffix, h)
325    }
326}
327
328fn scip_kind_to_file_type(_kind: &str) -> &'static str {
329    "code"
330}
331
332fn build_scip_metadata(symbol_id: &str, kind: &str, description: &str) -> HashMap<String, String> {
333    let mut meta = HashMap::new();
334    meta.insert("scip_symbol".to_string(), symbol_id.to_string());
335    meta.insert("scip_kind".to_string(), kind.to_string());
336    if !description.is_empty() {
337        meta.insert("scip_description".to_string(), description.to_string());
338    }
339    meta
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use serde_json::json;
346
347    // --- ingest_scip_json: invalid inputs ---
348
349    #[test]
350    fn test_non_dict_input_returns_empty() {
351        let result = ingest_scip_json(&json!(null), "", "python");
352        assert_eq!(result.nodes.len(), 0);
353        assert_eq!(result.edges.len(), 0);
354    }
355
356    #[test]
357    fn test_array_input_returns_empty() {
358        let result = ingest_scip_json(&json!([1, 2, 3]), "", "python");
359        assert_eq!(result.nodes.len(), 0);
360    }
361
362    #[test]
363    fn test_string_input_returns_empty() {
364        let result = ingest_scip_json(&json!("hello"), "", "python");
365        assert_eq!(result.nodes.len(), 0);
366    }
367
368    #[test]
369    fn test_no_documents_key_returns_empty() {
370        let result = ingest_scip_json(&json!({"other": "data"}), "", "python");
371        assert_eq!(result.nodes.len(), 0);
372    }
373
374    #[test]
375    fn test_documents_not_array_returns_empty() {
376        let result = ingest_scip_json(&json!({"documents": "bad"}), "", "python");
377        assert_eq!(result.nodes.len(), 0);
378    }
379
380    #[test]
381    fn test_empty_documents_array() {
382        let result = ingest_scip_json(&json!({"documents": []}), "", "python");
383        assert_eq!(result.nodes.len(), 0);
384        assert_eq!(result.edges.len(), 0);
385    }
386
387    #[test]
388    fn test_non_dict_document_skipped() {
389        let result = ingest_scip_json(&json!({"documents": ["bad", 42, null]}), "", "python");
390        assert_eq!(result.nodes.len(), 0);
391    }
392
393    // --- basic node emission ---
394
395    #[test]
396    fn test_single_symbol_produces_one_node() {
397        let doc = json!({
398            "documents": [{
399                "relative_path": "foo.py",
400                "language": "python",
401                "symbols": [{
402                    "symbol": "foo.py#MyClass",
403                    "kind": "class",
404                    "display_name": "MyClass",
405                    "documentation": ["A class doc"],
406                    "relationships": [],
407                    "occurrences": [{"range": [10, 0, 10, 7], "symbol": "foo.py#MyClass", "symbol_roles": 1}]
408                }]
409            }]
410        });
411        let result = ingest_scip_json(&doc, "foo.py", "python");
412        assert_eq!(result.nodes.len(), 1);
413        assert_eq!(result.nodes[0].label, "MyClass");
414        assert_eq!(result.nodes[0].file_type, "code");
415        assert_eq!(result.nodes[0].source_file, "foo.py");
416        assert_eq!(result.nodes[0].source_location, Some("L10".to_string()));
417        assert_eq!(
418            result.nodes[0]
419                .metadata
420                .get("scip_kind")
421                .map(|s| s.as_str()),
422            Some("class")
423        );
424        assert_eq!(
425            result.nodes[0]
426                .metadata
427                .get("scip_description")
428                .map(|s| s.as_str()),
429            Some("A class doc")
430        );
431    }
432
433    #[test]
434    fn test_multiple_symbols_multiple_nodes() {
435        let doc = json!({
436            "documents": [{
437                "relative_path": "bar.py",
438                "language": "python",
439                "symbols": [
440                    {"symbol": "bar.py#Foo", "kind": "class", "display_name": "Foo", "relationships": []},
441                    {"symbol": "bar.py#bar_fn", "kind": "function", "display_name": "bar_fn", "relationships": []}
442                ]
443            }]
444        });
445        let result = ingest_scip_json(&doc, "", "python");
446        assert_eq!(result.nodes.len(), 2);
447        let labels: Vec<&str> = result.nodes.iter().map(|n| n.label.as_str()).collect();
448        assert!(labels.contains(&"Foo"));
449        assert!(labels.contains(&"bar_fn"));
450    }
451
452    #[test]
453    fn test_symbol_without_display_name_uses_suffix() {
454        let doc = json!({
455            "documents": [{
456                "relative_path": "a.py",
457                "language": "python",
458                "symbols": [{
459                    "symbol": "pkg/mod#MyFunc",
460                    "kind": "function",
461                    "relationships": []
462                }]
463            }]
464        });
465        let result = ingest_scip_json(&doc, "", "python");
466        assert_eq!(result.nodes.len(), 1);
467        assert_eq!(result.nodes[0].label, "MyFunc");
468    }
469
470    #[test]
471    fn test_symbol_with_empty_symbol_id_skipped() {
472        let doc = json!({
473            "documents": [{
474                "relative_path": "x.py",
475                "language": "python",
476                "symbols": [
477                    {"symbol": "", "kind": "function", "display_name": "empty", "relationships": []},
478                    {"symbol": "x.py#Valid", "kind": "class", "display_name": "Valid", "relationships": []}
479                ]
480            }]
481        });
482        let result = ingest_scip_json(&doc, "", "python");
483        assert_eq!(result.nodes.len(), 1);
484        assert_eq!(result.nodes[0].label, "Valid");
485    }
486
487    #[test]
488    fn test_node_id_is_stable_and_unique() {
489        let doc = json!({
490            "documents": [{
491                "relative_path": "stable.py",
492                "language": "python",
493                "symbols": [
494                    {"symbol": "stable.py#A", "kind": "class", "display_name": "A", "relationships": []},
495                    {"symbol": "stable.py#B", "kind": "class", "display_name": "B", "relationships": []}
496                ]
497            }]
498        });
499        let result = ingest_scip_json(&doc, "", "python");
500        assert_eq!(result.nodes.len(), 2);
501        let id_a = result.nodes[0].id.clone();
502        let id_b = result.nodes[1].id.clone();
503        assert_ne!(id_a, id_b);
504        // Re-ingest should produce same IDs
505        let result2 = ingest_scip_json(&doc, "", "python");
506        assert_eq!(result2.nodes[0].id, id_a);
507        assert_eq!(result2.nodes[1].id, id_b);
508    }
509
510    #[test]
511    fn test_node_id_starts_with_scip_prefix() {
512        let doc = json!({
513            "documents": [{
514                "relative_path": "x.py",
515                "language": "python",
516                "symbols": [{"symbol": "x.py#Foo", "kind": "class", "display_name": "Foo", "relationships": []}]
517            }]
518        });
519        let result = ingest_scip_json(&doc, "", "python");
520        assert!(result.nodes[0].id.starts_with("scip_"));
521    }
522
523    // --- occurrence / source_location ---
524
525    #[test]
526    fn test_no_occurrences_no_source_location() {
527        let doc = json!({
528            "documents": [{
529                "relative_path": "x.py",
530                "language": "python",
531                "symbols": [{
532                    "symbol": "x.py#NoLoc",
533                    "kind": "function",
534                    "display_name": "NoLoc",
535                    "relationships": [],
536                    "occurrences": []
537                }]
538            }]
539        });
540        let result = ingest_scip_json(&doc, "", "python");
541        assert_eq!(result.nodes[0].source_location, None);
542    }
543
544    #[test]
545    fn test_occurrence_line_zero_no_source_location() {
546        let doc = json!({
547            "documents": [{
548                "relative_path": "x.py",
549                "language": "python",
550                "symbols": [{
551                    "symbol": "x.py#ZeroLine",
552                    "kind": "function",
553                    "display_name": "ZeroLine",
554                    "relationships": [],
555                    "occurrences": [{"range": [0, 0, 0, 5]}]
556                }]
557            }]
558        });
559        let result = ingest_scip_json(&doc, "", "python");
560        assert_eq!(result.nodes[0].source_location, None);
561    }
562
563    #[test]
564    fn test_occurrence_line_positive_sets_source_location() {
565        let doc = json!({
566            "documents": [{
567                "relative_path": "x.py",
568                "language": "python",
569                "symbols": [{
570                    "symbol": "x.py#HasLine",
571                    "kind": "function",
572                    "display_name": "HasLine",
573                    "relationships": [],
574                    "occurrences": [{"range": [42, 0, 42, 8]}]
575                }]
576            }]
577        });
578        let result = ingest_scip_json(&doc, "", "python");
579        assert_eq!(result.nodes[0].source_location, Some("L42".to_string()));
580    }
581
582    // --- documentation ---
583
584    #[test]
585    fn test_first_doc_string_becomes_description() {
586        let doc = json!({
587            "documents": [{
588                "relative_path": "d.py",
589                "language": "python",
590                "symbols": [{
591                    "symbol": "d.py#Fn",
592                    "kind": "function",
593                    "display_name": "Fn",
594                    "documentation": ["First doc", "Second doc"],
595                    "relationships": []
596                }]
597            }]
598        });
599        let result = ingest_scip_json(&doc, "", "python");
600        assert_eq!(
601            result.nodes[0]
602                .metadata
603                .get("scip_description")
604                .map(|s| s.as_str()),
605            Some("First doc")
606        );
607    }
608
609    #[test]
610    fn test_empty_docs_no_description_key() {
611        let doc = json!({
612            "documents": [{
613                "relative_path": "d.py",
614                "language": "python",
615                "symbols": [{
616                    "symbol": "d.py#Fn",
617                    "kind": "function",
618                    "display_name": "Fn",
619                    "documentation": [],
620                    "relationships": []
621                }]
622            }]
623        });
624        let result = ingest_scip_json(&doc, "", "python");
625        assert!(!result.nodes[0].metadata.contains_key("scip_description"));
626    }
627
628    // --- relationships ---
629
630    #[test]
631    fn test_reference_relationship_produces_edge() {
632        let doc = json!({
633            "documents": [{
634                "relative_path": "r.py",
635                "language": "python",
636                "symbols": [
637                    {
638                        "symbol": "r.py#Caller",
639                        "kind": "function",
640                        "display_name": "Caller",
641                        "relationships": [{"symbol": "r.py#Callee", "is_reference": true}]
642                    },
643                    {
644                        "symbol": "r.py#Callee",
645                        "kind": "function",
646                        "display_name": "Callee",
647                        "relationships": []
648                    }
649                ]
650            }]
651        });
652        let result = ingest_scip_json(&doc, "", "python");
653        assert_eq!(result.nodes.len(), 2);
654        assert_eq!(result.edges.len(), 1);
655        assert_eq!(result.edges[0].relation, "scip_ref");
656    }
657
658    #[test]
659    fn test_is_implementation_relation() {
660        let doc = json!({
661            "documents": [{
662                "relative_path": "impl.py",
663                "language": "python",
664                "symbols": [
665                    {
666                        "symbol": "impl.py#Concrete",
667                        "kind": "class",
668                        "display_name": "Concrete",
669                        "relationships": [{"symbol": "impl.py#Interface", "is_implementation": true}]
670                    },
671                    {
672                        "symbol": "impl.py#Interface",
673                        "kind": "interface",
674                        "display_name": "Interface",
675                        "relationships": []
676                    }
677                ]
678            }]
679        });
680        let result = ingest_scip_json(&doc, "", "python");
681        let impl_edges: Vec<&Edge> = result
682            .edges
683            .iter()
684            .filter(|e| e.relation == "scip_impl")
685            .collect();
686        assert_eq!(impl_edges.len(), 1);
687    }
688
689    #[test]
690    fn test_is_type_definition_relation() {
691        let doc = json!({
692            "documents": [{
693                "relative_path": "td.py",
694                "language": "python",
695                "symbols": [
696                    {
697                        "symbol": "td.py#Alias",
698                        "kind": "type",
699                        "display_name": "Alias",
700                        "relationships": [{"symbol": "td.py#Base", "is_type_definition": true}]
701                    },
702                    {"symbol": "td.py#Base", "kind": "class", "display_name": "Base", "relationships": []}
703                ]
704            }]
705        });
706        let result = ingest_scip_json(&doc, "", "python");
707        let typed_edges: Vec<&Edge> = result
708            .edges
709            .iter()
710            .filter(|e| e.relation == "scip_typed")
711            .collect();
712        assert_eq!(typed_edges.len(), 1);
713    }
714
715    #[test]
716    fn test_is_definition_relation() {
717        let doc = json!({
718            "documents": [{
719                "relative_path": "def.py",
720                "language": "python",
721                "symbols": [
722                    {
723                        "symbol": "def.py#A",
724                        "kind": "function",
725                        "display_name": "A",
726                        "relationships": [{"symbol": "def.py#B", "is_definition": true}]
727                    },
728                    {"symbol": "def.py#B", "kind": "function", "display_name": "B", "relationships": []}
729                ]
730            }]
731        });
732        let result = ingest_scip_json(&doc, "", "python");
733        let def_edges: Vec<&Edge> = result
734            .edges
735            .iter()
736            .filter(|e| e.relation == "scip_def")
737            .collect();
738        assert_eq!(def_edges.len(), 1);
739    }
740
741    #[test]
742    fn test_string_true_is_not_is_true() {
743        // Guard against "false" string or other truthy values being accepted
744        let doc = json!({
745            "documents": [{
746                "relative_path": "x.py",
747                "language": "python",
748                "symbols": [
749                    {
750                        "symbol": "x.py#A",
751                        "kind": "function",
752                        "display_name": "A",
753                        "relationships": [{"symbol": "x.py#B", "is_implementation": "true"}]
754                    },
755                    {"symbol": "x.py#B", "kind": "function", "display_name": "B", "relationships": []}
756                ]
757            }]
758        });
759        let result = ingest_scip_json(&doc, "", "python");
760        // "true" string should not be treated as is_implementation
761        let impl_edges: Vec<&Edge> = result
762            .edges
763            .iter()
764            .filter(|e| e.relation == "scip_impl")
765            .collect();
766        assert_eq!(impl_edges.len(), 0);
767        // But edge should still be emitted as scip_ref
768        assert_eq!(result.edges.len(), 1);
769        assert_eq!(result.edges[0].relation, "scip_ref");
770    }
771
772    // --- cross-document resolution ---
773
774    #[test]
775    fn test_cross_document_unique_resolution() {
776        let doc = json!({
777            "documents": [
778                {
779                    "relative_path": "a.py",
780                    "language": "python",
781                    "symbols": [{
782                        "symbol": "a.py#Fn",
783                        "kind": "function",
784                        "display_name": "Fn",
785                        "relationships": [{"symbol": "b.py#Helper", "is_reference": true}]
786                    }]
787                },
788                {
789                    "relative_path": "b.py",
790                    "language": "python",
791                    "symbols": [{
792                        "symbol": "b.py#Helper",
793                        "kind": "function",
794                        "display_name": "Helper",
795                        "relationships": []
796                    }]
797                }
798            ]
799        });
800        let result = ingest_scip_json(&doc, "", "python");
801        assert_eq!(result.nodes.len(), 2);
802        assert_eq!(result.edges.len(), 1);
803        // Target should be the real Helper node, not a stub
804        let target = &result.edges[0].target;
805        let helper_node = result.nodes.iter().find(|n| n.label == "Helper").unwrap();
806        assert_eq!(target, &helper_node.id);
807    }
808
809    #[test]
810    fn test_ambiguous_cross_document_creates_stub() {
811        // Same symbol defined in two documents → ambiguous → stub
812        let doc = json!({
813            "documents": [
814                {
815                    "relative_path": "a.py",
816                    "language": "python",
817                    "symbols": [{
818                        "symbol": "a.py#Caller",
819                        "kind": "function",
820                        "display_name": "Caller",
821                        "relationships": [{"symbol": "shared#Fn", "is_reference": true}]
822                    }]
823                },
824                {
825                    "relative_path": "b.py",
826                    "language": "python",
827                    "symbols": [{"symbol": "shared#Fn", "kind": "function", "display_name": "FnB", "relationships": []}]
828                },
829                {
830                    "relative_path": "c.py",
831                    "language": "python",
832                    "symbols": [{"symbol": "shared#Fn", "kind": "function", "display_name": "FnC", "relationships": []}]
833                }
834            ]
835        });
836        let result = ingest_scip_json(&doc, "", "python");
837        // Should have 3 real nodes + 1 stub
838        assert_eq!(result.nodes.len(), 4);
839        let stubs: Vec<&Node> = result
840            .nodes
841            .iter()
842            .filter(|n| n.metadata.get("scip_kind").map(|s| s.as_str()) == Some("external"))
843            .collect();
844        assert_eq!(stubs.len(), 1);
845    }
846
847    #[test]
848    fn test_external_symbol_creates_stub_node() {
849        let doc = json!({
850            "documents": [{
851                "relative_path": "main.py",
852                "language": "python",
853                "symbols": [{
854                    "symbol": "main.py#MyFn",
855                    "kind": "function",
856                    "display_name": "MyFn",
857                    "relationships": [{"symbol": "external.lib#ExternalFn", "is_reference": true}]
858                }]
859            }]
860        });
861        let result = ingest_scip_json(&doc, "", "python");
862        assert_eq!(result.nodes.len(), 2);
863        let stub = result
864            .nodes
865            .iter()
866            .find(|n| n.metadata.get("scip_kind").map(|s| s.as_str()) == Some("external"));
867        assert!(stub.is_some());
868    }
869
870    // --- deduplication ---
871
872    #[test]
873    fn test_duplicate_symbol_in_same_doc_deduped() {
874        let doc = json!({
875            "documents": [{
876                "relative_path": "dup.py",
877                "language": "python",
878                "symbols": [
879                    {"symbol": "dup.py#Foo", "kind": "class", "display_name": "Foo", "relationships": []},
880                    {"symbol": "dup.py#Foo", "kind": "class", "display_name": "Foo", "relationships": []}
881                ]
882            }]
883        });
884        let result = ingest_scip_json(&doc, "", "python");
885        assert_eq!(result.nodes.len(), 1);
886    }
887
888    #[test]
889    fn test_duplicate_edges_deduped() {
890        let doc = json!({
891            "documents": [{
892                "relative_path": "dup.py",
893                "language": "python",
894                "symbols": [
895                    {
896                        "symbol": "dup.py#A",
897                        "kind": "function",
898                        "display_name": "A",
899                        "relationships": [
900                            {"symbol": "dup.py#B", "is_reference": true},
901                            {"symbol": "dup.py#B", "is_reference": true}
902                        ]
903                    },
904                    {"symbol": "dup.py#B", "kind": "function", "display_name": "B", "relationships": []}
905                ]
906            }]
907        });
908        let result = ingest_scip_json(&doc, "", "python");
909        assert_eq!(result.edges.len(), 1);
910    }
911
912    // --- fallback values ---
913
914    #[test]
915    fn test_relative_path_from_doc_overrides_default() {
916        let doc = json!({
917            "documents": [{
918                "relative_path": "override/path.py",
919                "language": "python",
920                "symbols": [{
921                    "symbol": "override/path.py#Fn",
922                    "kind": "function",
923                    "display_name": "Fn",
924                    "relationships": []
925                }]
926            }]
927        });
928        let result = ingest_scip_json(&doc, "default.py", "python");
929        assert_eq!(result.nodes[0].source_file, "override/path.py");
930    }
931
932    #[test]
933    fn test_default_source_file_used_when_no_relative_path() {
934        let doc = json!({
935            "documents": [{
936                "language": "python",
937                "symbols": [{
938                    "symbol": "mod#Fn",
939                    "kind": "function",
940                    "display_name": "Fn",
941                    "relationships": []
942                }]
943            }]
944        });
945        let result = ingest_scip_json(&doc, "fallback.py", "python");
946        assert_eq!(result.nodes[0].source_file, "fallback.py");
947    }
948
949    #[test]
950    fn test_relationship_missing_symbol_skipped() {
951        let doc = json!({
952            "documents": [{
953                "relative_path": "x.py",
954                "language": "python",
955                "symbols": [{
956                    "symbol": "x.py#A",
957                    "kind": "function",
958                    "display_name": "A",
959                    "relationships": [{"is_reference": true}]
960                }]
961            }]
962        });
963        let result = ingest_scip_json(&doc, "", "python");
964        assert_eq!(result.nodes.len(), 1);
965        assert_eq!(result.edges.len(), 0);
966    }
967
968    #[test]
969    fn test_non_dict_relationship_skipped() {
970        let doc = json!({
971            "documents": [{
972                "relative_path": "x.py",
973                "language": "python",
974                "symbols": [{
975                    "symbol": "x.py#A",
976                    "kind": "function",
977                    "display_name": "A",
978                    "relationships": ["bad", 42, null]
979                }]
980            }]
981        });
982        let result = ingest_scip_json(&doc, "", "python");
983        assert_eq!(result.nodes.len(), 1);
984        assert_eq!(result.edges.len(), 0);
985    }
986
987    // --- multi-document ---
988
989    #[test]
990    fn test_multiple_documents_all_processed() {
991        let doc = json!({
992            "documents": [
993                {
994                    "relative_path": "mod1.py",
995                    "language": "python",
996                    "symbols": [{"symbol": "mod1.py#A", "kind": "class", "display_name": "A", "relationships": []}]
997                },
998                {
999                    "relative_path": "mod2.py",
1000                    "language": "python",
1001                    "symbols": [{"symbol": "mod2.py#B", "kind": "class", "display_name": "B", "relationships": []}]
1002                }
1003            ]
1004        });
1005        let result = ingest_scip_json(&doc, "", "python");
1006        assert_eq!(result.nodes.len(), 2);
1007        let files: Vec<&str> = result
1008            .nodes
1009            .iter()
1010            .map(|n| n.source_file.as_str())
1011            .collect();
1012        assert!(files.contains(&"mod1.py"));
1013        assert!(files.contains(&"mod2.py"));
1014    }
1015
1016    #[test]
1017    fn test_edge_confidence_is_extracted() {
1018        let doc = json!({
1019            "documents": [{
1020                "relative_path": "e.py",
1021                "language": "python",
1022                "symbols": [
1023                    {
1024                        "symbol": "e.py#A",
1025                        "kind": "function",
1026                        "display_name": "A",
1027                        "relationships": [{"symbol": "e.py#B", "is_reference": true}]
1028                    },
1029                    {"symbol": "e.py#B", "kind": "function", "display_name": "B", "relationships": []}
1030                ]
1031            }]
1032        });
1033        let result = ingest_scip_json(&doc, "", "python");
1034        assert_eq!(result.edges[0].confidence, "EXTRACTED");
1035        assert_eq!(result.edges[0].weight, 1.0);
1036    }
1037
1038    #[test]
1039    fn test_scip_symbol_in_metadata() {
1040        let doc = json!({
1041            "documents": [{
1042                "relative_path": "m.py",
1043                "language": "python",
1044                "symbols": [{
1045                    "symbol": "pkg/mod#MyClass",
1046                    "kind": "class",
1047                    "display_name": "MyClass",
1048                    "relationships": []
1049                }]
1050            }]
1051        });
1052        let result = ingest_scip_json(&doc, "", "python");
1053        assert_eq!(
1054            result.nodes[0]
1055                .metadata
1056                .get("scip_symbol")
1057                .map(|s| s.as_str()),
1058            Some("pkg/mod#MyClass")
1059        );
1060    }
1061
1062    // --- make_scip_node_id ---
1063
1064    #[test]
1065    fn test_make_scip_node_id_deterministic() {
1066        let id1 = make_scip_node_id("pkg#Fn", "file.py");
1067        let id2 = make_scip_node_id("pkg#Fn", "file.py");
1068        assert_eq!(id1, id2);
1069    }
1070
1071    #[test]
1072    fn test_make_scip_node_id_different_files_differ() {
1073        let id1 = make_scip_node_id("pkg#Fn", "a.py");
1074        let id2 = make_scip_node_id("pkg#Fn", "b.py");
1075        assert_ne!(id1, id2);
1076    }
1077
1078    #[test]
1079    fn test_make_scip_node_id_starts_with_scip() {
1080        let id = make_scip_node_id("pkg#Fn", "x.py");
1081        assert!(id.starts_with("scip_"));
1082    }
1083
1084    #[test]
1085    fn test_first_occurrence_line_non_list_returns_zero() {
1086        assert_eq!(first_occurrence_line(&json!(null)), 0);
1087        assert_eq!(first_occurrence_line(&json!("bad")), 0);
1088        assert_eq!(first_occurrence_line(&json!(42)), 0);
1089    }
1090
1091    #[test]
1092    fn test_first_occurrence_line_empty_list() {
1093        assert_eq!(first_occurrence_line(&json!([])), 0);
1094    }
1095
1096    #[test]
1097    fn test_first_occurrence_line_valid() {
1098        let occ = json!([{"range": [5, 0, 5, 3]}]);
1099        assert_eq!(first_occurrence_line(&occ), 5);
1100    }
1101
1102    #[test]
1103    fn test_is_true_exact_bool() {
1104        assert!(is_true(Some(&json!(true))));
1105        assert!(!is_true(Some(&json!(false))));
1106        assert!(!is_true(Some(&json!("true"))));
1107        assert!(!is_true(Some(&json!(1))));
1108        assert!(!is_true(None));
1109    }
1110}