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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 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 assert_eq!(result.edges.len(), 1);
769 assert_eq!(result.edges[0].relation, "scip_ref");
770 }
771
772 #[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 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 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 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 #[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 #[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 #[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 #[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}