1use std::collections::{HashMap, HashSet};
2
3use crate::types::{Edge, Node};
4
5pub fn normalise_callable_label(label: &str) -> String {
6 label
7 .trim()
8 .trim_matches(|c| c == '(' || c == ')')
9 .trim_start_matches('.')
10 .to_lowercase()
11}
12
13pub fn node_is_resolvable_symbol(node: &Node) -> bool {
14 if node.file_type != "code" {
15 return false;
16 }
17 let label = node.label.trim();
18 if label.is_empty() {
19 return false;
20 }
21 for ext in &[".py", ".js", ".ts", ".tsx", ".java", ".go", ".rs"] {
22 if label.ends_with(ext) {
23 return false;
24 }
25 }
26 !normalise_callable_label(label).is_empty()
27}
28
29pub fn build_label_index(nodes: &[Node]) -> HashMap<String, Vec<String>> {
30 let mut index: HashMap<String, Vec<String>> = HashMap::new();
31 for node in nodes {
32 if !node_is_resolvable_symbol(node) {
33 continue;
34 }
35 let key = normalise_callable_label(&node.label);
36 if key.is_empty() {
37 continue;
38 }
39 index.entry(key).or_default().push(node.id.clone());
40 }
41 index
42}
43
44pub fn existing_edge_pairs(edges: &[Edge]) -> HashSet<(String, String, String)> {
45 edges
46 .iter()
47 .map(|e| (e.source.clone(), e.target.clone(), e.relation.clone()))
48 .collect()
49}
50
51pub fn resolve_cross_file_raw_calls(
52 per_file: &[Option<RawCallsFragment>],
53 all_nodes: &[Node],
54 all_edges: &[Edge],
55) -> Vec<Edge> {
56 let label_index = build_label_index(all_nodes);
57 let mut known_pairs = existing_edge_pairs(all_edges);
58 let mut resolved: Vec<Edge> = Vec::new();
59
60 for fragment_opt in per_file {
61 let fragment = match fragment_opt {
62 Some(f) => f,
63 None => continue,
64 };
65 for raw_call in &fragment.raw_calls {
66 let callee = raw_call.callee.trim();
67 if callee.is_empty() {
68 continue;
69 }
70 if raw_call.is_member_call {
71 continue;
72 }
73 let candidates = label_index.get(&callee.to_lowercase());
74 let candidates = match candidates {
75 Some(c) if c.len() == 1 => c,
76 _ => continue,
77 };
78 let target = &candidates[0];
79 let caller = raw_call.caller_nid.trim();
80 if caller.is_empty() || caller == target.as_str() {
81 continue;
82 }
83 let pair = (caller.to_string(), target.clone(), "calls".to_string());
84 if known_pairs.contains(&pair) {
85 continue;
86 }
87 known_pairs.insert(pair);
88 resolved.push(Edge {
89 source: caller.to_string(),
90 target: target.clone(),
91 relation: "calls".to_string(),
92 confidence: "INFERRED".to_string(),
93 source_file: if raw_call.source_file.is_empty() {
94 None
95 } else {
96 Some(raw_call.source_file.clone())
97 },
98 weight: 1.0,
99 context: None,
100 });
101 }
102 }
103 resolved
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct RawCall {
108 pub callee: String,
109 pub caller_nid: String,
110 pub is_member_call: bool,
111 pub source_file: String,
112 pub source_location: String,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct RawCallsFragment {
117 pub raw_calls: Vec<RawCall>,
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use std::collections::HashMap;
124
125 fn make_code_node(id: &str, label: &str) -> Node {
126 Node {
127 id: id.to_string(),
128 label: label.to_string(),
129 file_type: "code".to_string(),
130 source_file: "test.py".to_string(),
131 source_location: None,
132 community: None,
133 rationale: None,
134 docstring: None,
135 metadata: HashMap::new(),
136 }
137 }
138
139 fn make_node_with_file(id: &str, label: &str, file: &str) -> Node {
140 Node {
141 id: id.to_string(),
142 label: label.to_string(),
143 file_type: "code".to_string(),
144 source_file: file.to_string(),
145 source_location: None,
146 community: None,
147 rationale: None,
148 docstring: None,
149 metadata: HashMap::new(),
150 }
151 }
152
153 fn make_doc_node(id: &str, label: &str) -> Node {
154 Node {
155 id: id.to_string(),
156 label: label.to_string(),
157 file_type: "document".to_string(),
158 source_file: "test.md".to_string(),
159 source_location: None,
160 community: None,
161 rationale: None,
162 docstring: None,
163 metadata: HashMap::new(),
164 }
165 }
166
167 fn make_edge(src: &str, tgt: &str, rel: &str) -> Edge {
168 Edge {
169 source: src.to_string(),
170 target: tgt.to_string(),
171 relation: rel.to_string(),
172 confidence: "EXTRACTED".to_string(),
173 source_file: None,
174 weight: 1.0,
175 context: None,
176 }
177 }
178
179 fn raw_call(callee: &str, caller_nid: &str) -> RawCall {
180 RawCall {
181 callee: callee.to_string(),
182 caller_nid: caller_nid.to_string(),
183 is_member_call: false,
184 source_file: "test.py".to_string(),
185 source_location: String::new(),
186 }
187 }
188
189 fn member_call(callee: &str, caller_nid: &str) -> RawCall {
190 RawCall {
191 callee: callee.to_string(),
192 caller_nid: caller_nid.to_string(),
193 is_member_call: true,
194 source_file: "test.py".to_string(),
195 source_location: String::new(),
196 }
197 }
198
199 #[test]
202 fn test_normalise_strips_parens() {
203 assert_eq!(normalise_callable_label("foo()"), "foo");
204 }
205
206 #[test]
207 fn test_normalise_lowercase() {
208 assert_eq!(normalise_callable_label("MyFunc"), "myfunc");
209 }
210
211 #[test]
212 fn test_normalise_strips_leading_dot() {
213 assert_eq!(normalise_callable_label(".foo"), "foo");
214 }
215
216 #[test]
217 fn test_normalise_strips_whitespace() {
218 assert_eq!(normalise_callable_label(" bar "), "bar");
219 }
220
221 #[test]
222 fn test_normalise_empty() {
223 assert_eq!(normalise_callable_label(""), "");
224 }
225
226 #[test]
229 fn test_code_node_is_resolvable() {
230 let node = make_code_node("n1", "my_fn");
231 assert!(node_is_resolvable_symbol(&node));
232 }
233
234 #[test]
235 fn test_document_node_not_resolvable() {
236 let node = make_doc_node("n1", "my_fn");
237 assert!(!node_is_resolvable_symbol(&node));
238 }
239
240 #[test]
241 fn test_empty_label_not_resolvable() {
242 let node = make_code_node("n1", "");
243 assert!(!node_is_resolvable_symbol(&node));
244 }
245
246 #[test]
247 fn test_py_extension_label_not_resolvable() {
248 let node = make_code_node("n1", "module.py");
249 assert!(!node_is_resolvable_symbol(&node));
250 }
251
252 #[test]
253 fn test_js_extension_label_not_resolvable() {
254 let node = make_code_node("n1", "module.js");
255 assert!(!node_is_resolvable_symbol(&node));
256 }
257
258 #[test]
259 fn test_ts_extension_label_not_resolvable() {
260 let node = make_code_node("n1", "module.ts");
261 assert!(!node_is_resolvable_symbol(&node));
262 }
263
264 #[test]
265 fn test_tsx_extension_label_not_resolvable() {
266 let node = make_code_node("n1", "component.tsx");
267 assert!(!node_is_resolvable_symbol(&node));
268 }
269
270 #[test]
271 fn test_java_extension_label_not_resolvable() {
272 let node = make_code_node("n1", "MyClass.java");
273 assert!(!node_is_resolvable_symbol(&node));
274 }
275
276 #[test]
277 fn test_go_extension_label_not_resolvable() {
278 let node = make_code_node("n1", "main.go");
279 assert!(!node_is_resolvable_symbol(&node));
280 }
281
282 #[test]
283 fn test_rs_extension_label_not_resolvable() {
284 let node = make_code_node("n1", "lib.rs");
285 assert!(!node_is_resolvable_symbol(&node));
286 }
287
288 #[test]
291 fn test_label_index_basic() {
292 let nodes = vec![make_code_node("n1", "my_fn")];
293 let index = build_label_index(&nodes);
294 assert!(index.contains_key("my_fn"));
295 assert_eq!(index["my_fn"], vec!["n1"]);
296 }
297
298 #[test]
299 fn test_label_index_case_insensitive() {
300 let nodes = vec![make_code_node("n1", "MyFn")];
301 let index = build_label_index(&nodes);
302 assert!(index.contains_key("myfn"));
303 }
304
305 #[test]
306 fn test_label_index_multiple_nodes_same_label() {
307 let nodes = vec![
308 make_node_with_file("n1", "fn_a", "a.py"),
309 make_node_with_file("n2", "fn_a", "b.py"),
310 ];
311 let index = build_label_index(&nodes);
312 assert_eq!(index["fn_a"].len(), 2);
313 }
314
315 #[test]
316 fn test_label_index_excludes_doc_nodes() {
317 let nodes = vec![make_doc_node("n1", "my_fn")];
318 let index = build_label_index(&nodes);
319 assert!(index.is_empty());
320 }
321
322 #[test]
323 fn test_label_index_empty_nodes() {
324 let index = build_label_index(&[]);
325 assert!(index.is_empty());
326 }
327
328 #[test]
331 fn test_existing_edge_pairs_basic() {
332 let edges = vec![make_edge("a", "b", "calls")];
333 let pairs = existing_edge_pairs(&edges);
334 assert!(pairs.contains(&("a".to_string(), "b".to_string(), "calls".to_string())));
335 }
336
337 #[test]
338 fn test_existing_edge_pairs_relation_distinguishes() {
339 let edges = vec![
340 make_edge("a", "b", "calls"),
341 make_edge("a", "b", "contains"),
342 ];
343 let pairs = existing_edge_pairs(&edges);
344 assert_eq!(pairs.len(), 2);
345 }
346
347 #[test]
348 fn test_existing_edge_pairs_empty() {
349 let pairs = existing_edge_pairs(&[]);
350 assert!(pairs.is_empty());
351 }
352
353 #[test]
356 fn test_resolve_unique_callee() {
357 let nodes = vec![make_code_node("target_id", "helper")];
358 let per_file = vec![Some(RawCallsFragment {
359 raw_calls: vec![raw_call("helper", "caller_id")],
360 })];
361 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
362 assert_eq!(resolved.len(), 1);
363 assert_eq!(resolved[0].source, "caller_id");
364 assert_eq!(resolved[0].target, "target_id");
365 assert_eq!(resolved[0].relation, "calls");
366 assert_eq!(resolved[0].confidence, "INFERRED");
367 }
368
369 #[test]
370 fn test_resolve_ambiguous_callee_skipped() {
371 let nodes = vec![
372 make_node_with_file("t1", "helper", "a.py"),
373 make_node_with_file("t2", "helper", "b.py"),
374 ];
375 let per_file = vec![Some(RawCallsFragment {
376 raw_calls: vec![raw_call("helper", "caller_id")],
377 })];
378 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
379 assert_eq!(resolved.len(), 0);
380 }
381
382 #[test]
383 fn test_resolve_member_call_skipped() {
384 let nodes = vec![make_code_node("target_id", "do_thing")];
385 let per_file = vec![Some(RawCallsFragment {
386 raw_calls: vec![member_call("do_thing", "caller_id")],
387 })];
388 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
389 assert_eq!(resolved.len(), 0);
390 }
391
392 #[test]
393 fn test_resolve_empty_callee_skipped() {
394 let nodes = vec![make_code_node("target_id", "fn")];
395 let per_file = vec![Some(RawCallsFragment {
396 raw_calls: vec![raw_call("", "caller_id")],
397 })];
398 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
399 assert_eq!(resolved.len(), 0);
400 }
401
402 #[test]
403 fn test_resolve_self_call_skipped() {
404 let nodes = vec![make_code_node("caller_id", "my_fn")];
405 let per_file = vec![Some(RawCallsFragment {
406 raw_calls: vec![raw_call("my_fn", "caller_id")],
407 })];
408 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
409 assert_eq!(resolved.len(), 0);
410 }
411
412 #[test]
413 fn test_resolve_existing_edge_not_duplicated() {
414 let nodes = vec![make_code_node("target_id", "helper")];
415 let existing = vec![make_edge("caller_id", "target_id", "calls")];
416 let per_file = vec![Some(RawCallsFragment {
417 raw_calls: vec![raw_call("helper", "caller_id")],
418 })];
419 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &existing);
420 assert_eq!(resolved.len(), 0);
421 }
422
423 #[test]
424 fn test_resolve_none_fragment_skipped() {
425 let nodes = vec![make_code_node("target_id", "helper")];
426 let per_file: Vec<Option<RawCallsFragment>> = vec![None];
427 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
428 assert_eq!(resolved.len(), 0);
429 }
430
431 #[test]
432 fn test_resolve_multiple_calls_deduped() {
433 let nodes = vec![make_code_node("target_id", "helper")];
434 let per_file = vec![Some(RawCallsFragment {
435 raw_calls: vec![
436 raw_call("helper", "caller_id"),
437 raw_call("helper", "caller_id"),
438 ],
439 })];
440 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
441 assert_eq!(resolved.len(), 1);
442 }
443
444 #[test]
445 fn test_resolve_case_insensitive_callee() {
446 let nodes = vec![make_code_node("target_id", "MyHelper")];
447 let per_file = vec![Some(RawCallsFragment {
448 raw_calls: vec![raw_call("myhelper", "caller_id")],
449 })];
450 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
451 assert_eq!(resolved.len(), 1);
452 }
453
454 #[test]
455 fn test_resolve_distinct_relation_does_not_suppress() {
456 let nodes = vec![make_code_node("target_id", "helper")];
458 let existing = vec![make_edge("caller_id", "target_id", "contains")];
459 let per_file = vec![Some(RawCallsFragment {
460 raw_calls: vec![raw_call("helper", "caller_id")],
461 })];
462 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &existing);
463 assert_eq!(resolved.len(), 1);
464 }
465
466 #[test]
467 fn test_resolve_no_nodes_no_edges() {
468 let per_file = vec![Some(RawCallsFragment {
469 raw_calls: vec![raw_call("helper", "caller_id")],
470 })];
471 let resolved = resolve_cross_file_raw_calls(&per_file, &[], &[]);
472 assert_eq!(resolved.len(), 0);
473 }
474
475 #[test]
476 fn test_resolve_empty_caller_nid_skipped() {
477 let nodes = vec![make_code_node("target_id", "helper")];
478 let per_file = vec![Some(RawCallsFragment {
479 raw_calls: vec![raw_call("helper", "")],
480 })];
481 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
482 assert_eq!(resolved.len(), 0);
483 }
484
485 #[test]
486 fn test_resolve_edge_weight_is_one() {
487 let nodes = vec![make_code_node("target_id", "helper")];
488 let per_file = vec![Some(RawCallsFragment {
489 raw_calls: vec![raw_call("helper", "caller_id")],
490 })];
491 let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
492 assert_eq!(resolved[0].weight, 1.0);
493 }
494}