1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8
9use super::deep_queries;
10use super::graph_provider::GraphProvider;
11use super::index_paths::normalize_project_root;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CallGraph {
19 pub project_root: String,
20 pub edges: Vec<CallEdge>,
21 pub file_hashes: HashMap<String, String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CallEdge {
26 pub caller_file: String,
27 pub caller_symbol: String,
28 pub caller_line: usize,
29 pub callee_name: String,
30}
31
32#[derive(Debug, Clone)]
35pub struct SymbolSpan {
36 pub file: String,
37 pub name: String,
38 pub start_line: usize,
39 pub end_line: usize,
40}
41
42#[derive(Debug, Clone, Default)]
48pub struct CallGraphInputs {
49 pub project_root: String,
50 pub file_paths: Vec<String>,
51 pub symbols: Vec<SymbolSpan>,
52 pub import_edges: Vec<(String, String)>,
54}
55
56impl CallGraphInputs {
57 pub fn open(project_root: &str) -> Self {
62 match crate::core::graph_provider::open_or_build(project_root) {
63 Some(open) => Self::from_provider(project_root, &open.provider),
64 None => Self {
65 project_root: normalize_project_root(project_root),
66 ..Default::default()
67 },
68 }
69 }
70
71 pub fn from_project_index(index: &super::graph_index::ProjectIndex) -> Self {
77 let symbols = index
78 .symbols
79 .values()
80 .map(|s| SymbolSpan {
81 file: s.file.clone(),
82 name: s.name.clone(),
83 start_line: s.start_line,
84 end_line: s.end_line,
85 })
86 .collect();
87 let import_edges = index
88 .edges
89 .iter()
90 .filter(|e| e.kind == "import" || e.kind == "reexport")
91 .map(|e| (e.from.clone(), e.to.clone()))
92 .collect();
93 Self {
94 project_root: index.project_root.clone(),
95 file_paths: index.files.keys().cloned().collect(),
96 symbols,
97 import_edges,
98 }
99 }
100
101 pub fn from_provider(project_root: &str, provider: &GraphProvider) -> Self {
103 let symbols = provider
104 .all_symbols()
105 .into_iter()
106 .map(|s| SymbolSpan {
107 file: s.file,
108 name: s.name,
109 start_line: s.start_line,
110 end_line: s.end_line,
111 })
112 .collect();
113 let import_edges = provider
114 .edges()
115 .into_iter()
116 .filter(|e| e.kind == "import" || e.kind == "reexport")
117 .map(|e| (e.from, e.to))
118 .collect();
119 Self {
120 project_root: normalize_project_root(project_root),
121 file_paths: provider.file_paths(),
122 symbols,
123 import_edges,
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
129pub struct BfsNode {
130 pub symbol: String,
131 pub file: String,
132 pub line: usize,
133 pub depth: usize,
134 pub from_symbol: String,
135}
136
137#[derive(Debug, Clone)]
138pub struct PathHop {
139 pub symbol: String,
140 pub file: String,
141 pub line: usize,
142}
143
144#[derive(Clone, Copy)]
145enum BfsDirection {
146 Callers,
147 Callees,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum RiskLevel {
152 Low,
153 Medium,
154 High,
155 Critical,
156}
157
158impl RiskLevel {
159 pub fn from_caller_count(count: usize) -> Self {
160 match count {
161 0..=1 => Self::Low,
162 2..=4 => Self::Medium,
163 5..=10 => Self::High,
164 _ => Self::Critical,
165 }
166 }
167
168 pub fn label(self) -> &'static str {
169 match self {
170 Self::Low => "LOW",
171 Self::Medium => "MEDIUM",
172 Self::High => "HIGH",
173 Self::Critical => "CRITICAL",
174 }
175 }
176}
177
178#[derive(Debug, Clone, Serialize)]
183pub struct BuildProgress {
184 pub status: &'static str,
185 pub files_total: usize,
186 pub files_done: usize,
187 pub edges_found: usize,
188}
189
190enum BuildState {
191 Idle,
192 Building {
193 files_total: usize,
194 files_done: Arc<AtomicUsize>,
195 edges_found: Arc<AtomicUsize>,
196 },
197 Ready(Arc<CallGraph>),
198 Failed(String),
199}
200
201static BUILD: OnceLock<Mutex<BuildState>> = OnceLock::new();
202
203fn global_state() -> &'static Mutex<BuildState> {
204 BUILD.get_or_init(|| Mutex::new(BuildState::Idle))
205}
206
207impl CallGraph {
208 pub fn new(project_root: &str) -> Self {
209 Self {
210 project_root: normalize_project_root(project_root),
211 edges: Vec::new(),
212 file_hashes: HashMap::new(),
213 }
214 }
215
216 pub fn build_parallel(
221 inputs: &CallGraphInputs,
222 progress: Option<(&AtomicUsize, &AtomicUsize)>,
223 ) -> Self {
224 let project_root = &inputs.project_root;
225 let symbols_by_file = group_symbols_by_file_owned(inputs);
226 let file_keys: Vec<String> = inputs.file_paths.clone();
227
228 let results: Vec<(String, String, Vec<CallEdge>)> = file_keys
229 .par_iter()
230 .filter_map(|rel_path| {
231 let abs_path = resolve_path(rel_path, project_root);
232 let content = std::fs::read_to_string(&abs_path).ok()?;
233 let hash = simple_hash(&content);
234
235 let ext = Path::new(rel_path)
236 .extension()
237 .and_then(|e| e.to_str())
238 .unwrap_or("");
239
240 let analysis = deep_queries::analyze(&content, ext);
241 let file_symbols = symbols_by_file.get(rel_path.as_str());
242
243 let edges: Vec<CallEdge> = analysis
244 .calls
245 .iter()
246 .map(|call| {
247 let caller_sym = find_enclosing_symbol_owned(file_symbols, call.line + 1);
248 CallEdge {
249 caller_file: rel_path.clone(),
250 caller_symbol: caller_sym,
251 caller_line: call.line + 1,
252 callee_name: call.callee.clone(),
253 }
254 })
255 .collect();
256
257 if let Some((done, edge_count)) = progress {
258 done.fetch_add(1, Ordering::Relaxed);
259 edge_count.fetch_add(edges.len(), Ordering::Relaxed);
260 }
261
262 Some((rel_path.clone(), hash, edges))
263 })
264 .collect();
265
266 let mut graph = Self::new(project_root);
267 let edge_capacity: usize = results.iter().map(|(_, _, e)| e.len()).sum();
268 graph.edges.reserve(edge_capacity);
269 graph.file_hashes.reserve(results.len());
270
271 for (path, hash, edges) in results {
272 graph.file_hashes.insert(path, hash);
273 graph.edges.extend(edges);
274 }
275
276 graph
277 }
278
279 pub fn build_incremental_parallel(
284 inputs: &CallGraphInputs,
285 previous: &CallGraph,
286 progress: Option<(&AtomicUsize, &AtomicUsize)>,
287 ) -> Self {
288 let project_root = &inputs.project_root;
289 let symbols_by_file = group_symbols_by_file_owned(inputs);
290 let file_keys: Vec<String> = inputs.file_paths.clone();
291
292 let prev_edges_by_file = group_edges_by_file(&previous.edges);
293
294 let results: Vec<(String, String, Vec<CallEdge>)> = file_keys
295 .par_iter()
296 .filter_map(|rel_path| {
297 let abs_path = resolve_path(rel_path, project_root);
298 let content = std::fs::read_to_string(&abs_path).ok()?;
299 let hash = simple_hash(&content);
300 let changed = previous.file_hashes.get(rel_path.as_str()) != Some(&hash);
301
302 let edges = if changed {
303 let ext = Path::new(rel_path)
304 .extension()
305 .and_then(|e| e.to_str())
306 .unwrap_or("");
307
308 let analysis = deep_queries::analyze(&content, ext);
309 let file_symbols = symbols_by_file.get(rel_path.as_str());
310
311 analysis
312 .calls
313 .iter()
314 .map(|call| {
315 let caller_sym =
316 find_enclosing_symbol_owned(file_symbols, call.line + 1);
317 CallEdge {
318 caller_file: rel_path.clone(),
319 caller_symbol: caller_sym,
320 caller_line: call.line + 1,
321 callee_name: call.callee.clone(),
322 }
323 })
324 .collect()
325 } else {
326 prev_edges_by_file
327 .get(rel_path.as_str())
328 .cloned()
329 .unwrap_or_default()
330 };
331
332 if let Some((done, edge_count)) = progress {
333 done.fetch_add(1, Ordering::Relaxed);
334 edge_count.fetch_add(edges.len(), Ordering::Relaxed);
335 }
336
337 Some((rel_path.clone(), hash, edges))
338 })
339 .collect();
340
341 let mut graph = Self::new(project_root);
342 let edge_capacity: usize = results.iter().map(|(_, _, e)| e.len()).sum();
343 graph.edges.reserve(edge_capacity);
344 graph.file_hashes.reserve(results.len());
345
346 for (path, hash, edges) in results {
347 graph.file_hashes.insert(path, hash);
348 graph.edges.extend(edges);
349 }
350
351 graph
352 }
353
354 pub fn get_or_start_build(
360 project_root: &str,
361 inputs: Arc<CallGraphInputs>,
362 ) -> Result<Arc<CallGraph>, BuildProgress> {
363 let state = global_state();
364 let mut guard = state
365 .lock()
366 .unwrap_or_else(std::sync::PoisonError::into_inner);
367
368 match &*guard {
369 BuildState::Ready(graph) => return Ok(Arc::clone(graph)),
370 BuildState::Building {
371 files_total,
372 files_done,
373 edges_found,
374 } => {
375 return Err(BuildProgress {
376 status: "building",
377 files_total: *files_total,
378 files_done: files_done.load(Ordering::Relaxed),
379 edges_found: edges_found.load(Ordering::Relaxed),
380 });
381 }
382 BuildState::Failed(msg) => {
383 tracing::warn!("[call_graph: previous build failed: {msg} — retrying]");
384 }
385 BuildState::Idle => {}
386 }
387
388 if let Some(cached) = Self::load(project_root)
390 && !cache_looks_stale(&cached, &inputs)
391 {
392 let arc = Arc::new(cached);
393 *guard = BuildState::Ready(Arc::clone(&arc));
394 return Ok(arc);
395 }
396
397 let files_total = inputs.file_paths.len();
398 let files_done = Arc::new(AtomicUsize::new(0));
399 let edges_found = Arc::new(AtomicUsize::new(0));
400
401 *guard = BuildState::Building {
402 files_total,
403 files_done: Arc::clone(&files_done),
404 edges_found: Arc::clone(&edges_found),
405 };
406 drop(guard);
407
408 let root = normalize_project_root(project_root);
409 let fd = Arc::clone(&files_done);
410 let ef = Arc::clone(&edges_found);
411
412 std::thread::spawn(move || {
413 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
414 let previous = CallGraph::load(&root);
415 if let Some(prev) = &previous {
416 CallGraph::build_incremental_parallel(&inputs, prev, Some((&fd, &ef)))
417 } else {
418 CallGraph::build_parallel(&inputs, Some((&fd, &ef)))
419 }
420 }));
421
422 match result {
423 Ok(graph) => {
424 let _ = graph.save();
425 let arc = Arc::new(graph);
426 if let Ok(mut g) = global_state().lock() {
427 *g = BuildState::Ready(Arc::clone(&arc));
428 }
429 tracing::info!(
430 "[call_graph: build complete — {} files, {} edges]",
431 arc.file_hashes.len(),
432 arc.edges.len()
433 );
434 }
435 Err(e) => {
436 let msg = format!("{e:?}");
437 tracing::error!("[call_graph: build panicked: {msg}]");
438 if let Ok(mut g) = global_state().lock() {
439 *g = BuildState::Failed(msg);
440 }
441 }
442 }
443 });
444
445 Err(BuildProgress {
446 status: "building",
447 files_total,
448 files_done: 0,
449 edges_found: 0,
450 })
451 }
452
453 pub fn build_status() -> BuildProgress {
455 let state = global_state();
456 let guard = state
457 .lock()
458 .unwrap_or_else(std::sync::PoisonError::into_inner);
459 match &*guard {
460 BuildState::Idle => BuildProgress {
461 status: "idle",
462 files_total: 0,
463 files_done: 0,
464 edges_found: 0,
465 },
466 BuildState::Building {
467 files_total,
468 files_done,
469 edges_found,
470 } => BuildProgress {
471 status: "building",
472 files_total: *files_total,
473 files_done: files_done.load(Ordering::Relaxed),
474 edges_found: edges_found.load(Ordering::Relaxed),
475 },
476 BuildState::Ready(graph) => BuildProgress {
477 status: "ready",
478 files_total: graph.file_hashes.len(),
479 files_done: graph.file_hashes.len(),
480 edges_found: graph.edges.len(),
481 },
482 BuildState::Failed(msg) => {
483 tracing::debug!("[call_graph: status check — failed: {msg}]");
484 BuildProgress {
485 status: "error",
486 files_total: 0,
487 files_done: 0,
488 edges_found: 0,
489 }
490 }
491 }
492 }
493
494 pub fn invalidate() {
496 if let Ok(mut g) = global_state().lock() {
497 *g = BuildState::Idle;
498 }
499 }
500
501 pub fn build(inputs: &CallGraphInputs) -> Self {
506 Self::build_parallel(inputs, None)
507 }
508
509 pub fn build_incremental(inputs: &CallGraphInputs, previous: &CallGraph) -> Self {
510 Self::build_incremental_parallel(inputs, previous, None)
511 }
512
513 pub fn callers_of(&self, symbol: &str) -> Vec<&CallEdge> {
514 let sym_lower = symbol.to_lowercase();
515 self.edges
516 .iter()
517 .filter(|e| e.callee_name.to_lowercase() == sym_lower)
518 .collect()
519 }
520
521 pub fn callees_of(&self, symbol: &str) -> Vec<&CallEdge> {
522 let sym_lower = symbol.to_lowercase();
523 self.edges
524 .iter()
525 .filter(|e| e.caller_symbol.to_lowercase() == sym_lower)
526 .collect()
527 }
528
529 pub fn bfs_callers(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
535 self.bfs_traverse(symbol, max_depth, BfsDirection::Callers)
536 }
537
538 pub fn bfs_callees(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
540 self.bfs_traverse(symbol, max_depth, BfsDirection::Callees)
541 }
542
543 fn bfs_traverse(&self, symbol: &str, max_depth: usize, dir: BfsDirection) -> Vec<BfsNode> {
544 use std::collections::{HashSet, VecDeque};
545
546 let mut visited: HashSet<String> = HashSet::new();
547 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
548 let mut result: Vec<BfsNode> = Vec::new();
549
550 let start = symbol.to_lowercase();
551 visited.insert(start.clone());
552 queue.push_back((start, 0));
553
554 while let Some((current, depth)) = queue.pop_front() {
555 if depth >= max_depth {
556 continue;
557 }
558
559 let neighbors: Vec<&CallEdge> = match dir {
560 BfsDirection::Callers => self
561 .edges
562 .iter()
563 .filter(|e| e.callee_name.to_lowercase() == current)
564 .collect(),
565 BfsDirection::Callees => self
566 .edges
567 .iter()
568 .filter(|e| e.caller_symbol.to_lowercase() == current)
569 .collect(),
570 };
571
572 for edge in neighbors {
573 let next_sym = match dir {
574 BfsDirection::Callers => &edge.caller_symbol,
575 BfsDirection::Callees => &edge.callee_name,
576 };
577 let next_lower = next_sym.to_lowercase();
578
579 if !visited.insert(next_lower.clone()) {
580 continue;
581 }
582
583 result.push(BfsNode {
584 symbol: next_sym.clone(),
585 file: edge.caller_file.clone(),
586 line: edge.caller_line,
587 depth: depth + 1,
588 from_symbol: if depth == 0 {
589 symbol.to_string()
590 } else {
591 current.clone()
592 },
593 });
594
595 queue.push_back((next_lower, depth + 1));
596 }
597 }
598
599 result
600 }
601
602 pub fn find_call_path(&self, from: &str, to: &str) -> Option<Vec<PathHop>> {
607 use std::collections::{HashMap as BfsMap, VecDeque};
608
609 let from_lower = from.to_lowercase();
610 let to_lower = to.to_lowercase();
611
612 if from_lower == to_lower {
613 return Some(vec![PathHop {
614 symbol: from.to_string(),
615 file: String::new(),
616 line: 0,
617 }]);
618 }
619
620 const MAX_TRACE_DEPTH: usize = 10;
621
622 let mut visited: BfsMap<String, (String, String, usize, usize)> = BfsMap::new();
624 let mut queue: VecDeque<String> = VecDeque::new();
625
626 visited.insert(from_lower.clone(), (String::new(), String::new(), 0, 0));
627 queue.push_back(from_lower.clone());
628
629 while let Some(current) = queue.pop_front() {
630 let current_depth = visited.get(¤t).map_or(0, |e| e.3);
631 if current_depth >= MAX_TRACE_DEPTH {
632 continue;
633 }
634
635 let callees: Vec<&CallEdge> = self
636 .edges
637 .iter()
638 .filter(|e| e.caller_symbol.to_lowercase() == current)
639 .collect();
640
641 for edge in callees {
642 let next = edge.callee_name.to_lowercase();
643 if visited.contains_key(&next) {
644 continue;
645 }
646
647 visited.insert(
648 next.clone(),
649 (
650 current.clone(),
651 edge.caller_file.clone(),
652 edge.caller_line,
653 current_depth + 1,
654 ),
655 );
656
657 if next == to_lower {
658 return Some(Self::reconstruct_path(
659 &visited,
660 &from_lower,
661 &to_lower,
662 from,
663 to,
664 ));
665 }
666
667 queue.push_back(next);
668 }
669 }
670
671 None
672 }
673
674 fn reconstruct_path(
675 visited: &std::collections::HashMap<String, (String, String, usize, usize)>,
676 from_lower: &str,
677 to_lower: &str,
678 from_orig: &str,
679 to_orig: &str,
680 ) -> Vec<PathHop> {
681 let mut path = Vec::new();
682 let mut current = to_lower.to_string();
683
684 while current != from_lower {
685 let (parent, file, line, _depth) = &visited[¤t];
686 let sym_name = if current == to_lower {
687 to_orig.to_string()
688 } else {
689 current.clone()
690 };
691 path.push(PathHop {
692 symbol: sym_name,
693 file: file.clone(),
694 line: *line,
695 });
696 current = parent.clone();
697 }
698
699 path.push(PathHop {
700 symbol: from_orig.to_string(),
701 file: String::new(),
702 line: 0,
703 });
704
705 path.reverse();
706 path
707 }
708
709 pub fn transitive_caller_count(&self, symbol: &str, max_depth: usize) -> usize {
711 let nodes = self.bfs_callers(symbol, max_depth);
712 let mut unique: std::collections::HashSet<String> = std::collections::HashSet::new();
713 for node in &nodes {
714 unique.insert(node.symbol.to_lowercase());
715 }
716 unique.len()
717 }
718
719 pub fn save(&self) -> Result<(), String> {
720 let dir = call_graph_dir(&self.project_root)
721 .ok_or_else(|| "Cannot determine home directory".to_string())?;
722 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
723 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
724 let compressed = zstd::encode_all(json.as_bytes(), 9).map_err(|e| format!("zstd: {e}"))?;
725 let target = dir.join("call_graph.json.zst");
726 let tmp = target.with_extension("zst.tmp");
727 std::fs::write(&tmp, &compressed).map_err(|e| e.to_string())?;
728 std::fs::rename(&tmp, &target).map_err(|e| e.to_string())?;
729 let _ = std::fs::remove_file(dir.join("call_graph.json"));
730 Ok(())
731 }
732
733 pub fn load(project_root: &str) -> Option<Self> {
734 let dir = call_graph_dir(project_root)?;
735
736 let zst_path = dir.join("call_graph.json.zst");
737 if zst_path.exists() {
738 let compressed = std::fs::read(&zst_path).ok()?;
739 let data = zstd::decode_all(compressed.as_slice()).ok()?;
740 let content = String::from_utf8(data).ok()?;
741 return serde_json::from_str(&content).ok();
742 }
743
744 let json_path = dir.join("call_graph.json");
745 if json_path.exists() {
746 let content = std::fs::read_to_string(&json_path).ok()?;
747 let parsed: Self = serde_json::from_str(&content).ok()?;
748 if let Ok(compressed) = zstd::encode_all(content.as_bytes(), 9) {
750 let zst_tmp = zst_path.with_extension("zst.tmp");
751 if std::fs::write(&zst_tmp, &compressed).is_ok()
752 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
753 {
754 let _ = std::fs::remove_file(&json_path);
755 }
756 }
757 return Some(parsed);
758 }
759
760 None
761 }
762
763 pub fn load_or_build(project_root: &str, inputs: &CallGraphInputs) -> Self {
764 if let Some(previous) = Self::load(project_root) {
765 Self::build_incremental(inputs, &previous)
766 } else {
767 Self::build(inputs)
768 }
769 }
770}
771
772fn cache_looks_stale(cached: &CallGraph, inputs: &CallGraphInputs) -> bool {
777 if cached.file_hashes.len() != inputs.file_paths.len() {
778 return true;
779 }
780 let cached_files: std::collections::HashSet<&str> =
781 cached.file_hashes.keys().map(String::as_str).collect();
782 let index_files: std::collections::HashSet<&str> =
783 inputs.file_paths.iter().map(String::as_str).collect();
784 cached_files != index_files
785}
786
787fn call_graph_dir(project_root: &str) -> Option<std::path::PathBuf> {
792 GraphProvider::index_dir(project_root)
793}
794
795fn group_edges_by_file(edges: &[CallEdge]) -> HashMap<&str, Vec<CallEdge>> {
796 let mut map: HashMap<&str, Vec<CallEdge>> = HashMap::new();
797 for edge in edges {
798 map.entry(edge.caller_file.as_str())
799 .or_default()
800 .push(edge.clone());
801 }
802 map
803}
804
805fn group_symbols_by_file_owned(inputs: &CallGraphInputs) -> HashMap<String, Vec<SymbolSpan>> {
807 let mut map: HashMap<String, Vec<SymbolSpan>> = HashMap::new();
808 for sym in &inputs.symbols {
809 map.entry(sym.file.clone()).or_default().push(sym.clone());
810 }
811 for syms in map.values_mut() {
812 syms.sort_by_key(|s| s.start_line);
813 }
814 map
815}
816
817fn find_enclosing_symbol_owned(file_symbols: Option<&Vec<SymbolSpan>>, line: usize) -> String {
818 let Some(syms) = file_symbols else {
819 return "<module>".to_string();
820 };
821 let mut best: Option<&SymbolSpan> = None;
822 for sym in syms {
823 if line >= sym.start_line && line <= sym.end_line {
824 match best {
825 None => best = Some(sym),
826 Some(prev) => {
827 if (sym.end_line - sym.start_line) < (prev.end_line - prev.start_line) {
828 best = Some(sym);
829 }
830 }
831 }
832 }
833 }
834 best.map_or_else(|| "<module>".to_string(), |s| s.name.clone())
835}
836
837fn resolve_path(relative: &str, project_root: &str) -> String {
838 let p = Path::new(relative);
839 if p.is_absolute() && p.exists() {
840 return relative.to_string();
841 }
842 let relative = relative.trim_start_matches(['/', '\\']);
843 let joined = Path::new(project_root).join(relative);
844 joined.to_string_lossy().to_string()
845}
846
847fn simple_hash(content: &str) -> String {
848 use std::hash::{Hash, Hasher};
849 let mut hasher = std::collections::hash_map::DefaultHasher::new();
850 content.hash(&mut hasher);
851 format!("{:x}", hasher.finish())
852}
853
854pub fn build_import_adjacency(
866 inputs: &CallGraphInputs,
867) -> HashMap<String, std::collections::HashSet<String>> {
868 let mut adj: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
869 for (from, to) in &inputs.import_edges {
870 adj.entry(from.clone()).or_default().insert(to.clone());
871 }
872 adj
873}
874
875fn rank_callee_def_file(
883 def_files: &[&str],
884 caller_file: &str,
885 imports: &HashMap<String, std::collections::HashSet<String>>,
886) -> Option<String> {
887 if def_files.is_empty() {
888 return None;
889 }
890 if def_files.contains(&caller_file) {
891 return Some(caller_file.to_string());
892 }
893 if let Some(imported) = imports.get(caller_file) {
894 let mut in_scope = def_files.iter().filter(|f| imported.contains(**f));
895 if let Some(first) = in_scope.next()
896 && in_scope.next().is_none()
897 {
898 return Some((*first).to_string());
899 }
900 }
901 if def_files.len() == 1 {
902 return Some(def_files[0].to_string());
903 }
904 None
905}
906
907pub fn resolve_callee_file(
909 callee_name: &str,
910 caller_file: &str,
911 inputs: &CallGraphInputs,
912 imports: &HashMap<String, std::collections::HashSet<String>>,
913) -> Option<String> {
914 let mut def_files: Vec<&str> = inputs
915 .symbols
916 .iter()
917 .filter(|s| s.name == callee_name)
918 .map(|s| s.file.as_str())
919 .collect();
920 def_files.sort_unstable();
921 def_files.dedup();
922 rank_callee_def_file(&def_files, caller_file, imports)
923}
924
925pub fn resolve_callee_files(
930 inputs: &CallGraphInputs,
931 edges: &[CallEdge],
932) -> HashMap<String, String> {
933 use std::collections::HashSet;
934
935 let imports = build_import_adjacency(inputs);
936 let callee_names: HashSet<&str> = edges.iter().map(|e| e.callee_name.as_str()).collect();
937 if callee_names.is_empty() {
938 return HashMap::new();
939 }
940
941 let mut name_files: HashMap<&str, Vec<&str>> = HashMap::new();
942 for sym in &inputs.symbols {
943 if callee_names.contains(sym.name.as_str()) {
944 name_files
945 .entry(sym.name.as_str())
946 .or_default()
947 .push(sym.file.as_str());
948 }
949 }
950 for files in name_files.values_mut() {
951 files.sort_unstable();
952 files.dedup();
953 }
954
955 let mut resolved: HashMap<&str, HashSet<String>> = HashMap::new();
956 for e in edges {
957 if let Some(defs) = name_files.get(e.callee_name.as_str())
958 && let Some(file) = rank_callee_def_file(defs, &e.caller_file, &imports)
959 {
960 resolved
961 .entry(e.callee_name.as_str())
962 .or_default()
963 .insert(file);
964 }
965 }
966
967 resolved
968 .into_iter()
969 .filter_map(|(name, files)| {
970 (files.len() == 1).then(|| (name.to_string(), files.into_iter().next().unwrap()))
971 })
972 .collect()
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978
979 #[test]
980 fn callers_of_empty_graph() {
981 let graph = CallGraph::new("/tmp");
982 assert!(graph.callers_of("foo").is_empty());
983 }
984
985 #[test]
986 fn callers_of_finds_edges() {
987 let mut graph = CallGraph::new("/tmp");
988 graph.edges.push(CallEdge {
989 caller_file: "a.rs".to_string(),
990 caller_symbol: "bar".to_string(),
991 caller_line: 10,
992 callee_name: "foo".to_string(),
993 });
994 graph.edges.push(CallEdge {
995 caller_file: "b.rs".to_string(),
996 caller_symbol: "baz".to_string(),
997 caller_line: 20,
998 callee_name: "foo".to_string(),
999 });
1000 graph.edges.push(CallEdge {
1001 caller_file: "c.rs".to_string(),
1002 caller_symbol: "qux".to_string(),
1003 caller_line: 30,
1004 callee_name: "other".to_string(),
1005 });
1006 let callers = graph.callers_of("foo");
1007 assert_eq!(callers.len(), 2);
1008 }
1009
1010 #[test]
1011 fn callees_of_finds_edges() {
1012 let mut graph = CallGraph::new("/tmp");
1013 graph.edges.push(CallEdge {
1014 caller_file: "a.rs".to_string(),
1015 caller_symbol: "main".to_string(),
1016 caller_line: 5,
1017 callee_name: "init".to_string(),
1018 });
1019 graph.edges.push(CallEdge {
1020 caller_file: "a.rs".to_string(),
1021 caller_symbol: "main".to_string(),
1022 caller_line: 6,
1023 callee_name: "run".to_string(),
1024 });
1025 graph.edges.push(CallEdge {
1026 caller_file: "a.rs".to_string(),
1027 caller_symbol: "other".to_string(),
1028 caller_line: 15,
1029 callee_name: "init".to_string(),
1030 });
1031 let callees = graph.callees_of("main");
1032 assert_eq!(callees.len(), 2);
1033 }
1034
1035 fn sym(name: &str, file: &str) -> SymbolSpan {
1036 SymbolSpan {
1037 file: file.to_string(),
1038 name: name.to_string(),
1039 start_line: 1,
1040 end_line: 2,
1041 }
1042 }
1043
1044 #[test]
1045 fn resolve_callee_file_scopes_same_named_methods() {
1046 let inputs = CallGraphInputs {
1049 project_root: "/p".to_string(),
1050 symbols: vec![sym("Run", "a.rs"), sym("Run", "b.rs")],
1051 ..Default::default()
1052 };
1053 let imports: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1054
1055 assert_eq!(
1056 resolve_callee_file("Run", "a.rs", &inputs, &imports).as_deref(),
1057 Some("a.rs")
1058 );
1059 assert_eq!(
1060 resolve_callee_file("Run", "b.rs", &inputs, &imports).as_deref(),
1061 Some("b.rs")
1062 );
1063 assert_eq!(resolve_callee_file("Run", "c.rs", &inputs, &imports), None);
1065 }
1066
1067 #[test]
1068 fn resolve_callee_file_prefers_imported_definition() {
1069 let inputs = CallGraphInputs {
1070 project_root: "/p".to_string(),
1071 symbols: vec![sym("Run", "lib.rs"), sym("Run", "other.rs")],
1072 ..Default::default()
1073 };
1074 let mut imports: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1075 imports.insert(
1076 "main.rs".to_string(),
1077 std::collections::HashSet::from(["lib.rs".to_string()]),
1078 );
1079 assert_eq!(
1082 resolve_callee_file("Run", "main.rs", &inputs, &imports).as_deref(),
1083 Some("lib.rs")
1084 );
1085 }
1086
1087 #[test]
1088 fn resolve_callee_files_drops_cross_scope_ambiguity() {
1089 let inputs = CallGraphInputs {
1090 project_root: "/p".to_string(),
1091 symbols: vec![
1092 sym("Run", "a.rs"),
1093 sym("Run", "b.rs"),
1094 sym("Unique", "u.rs"),
1095 ],
1096 ..Default::default()
1097 };
1098 let edges = vec![
1099 CallEdge {
1100 caller_file: "a.rs".into(),
1101 caller_symbol: "fa".into(),
1102 caller_line: 1,
1103 callee_name: "Run".into(),
1104 },
1105 CallEdge {
1106 caller_file: "b.rs".into(),
1107 caller_symbol: "fb".into(),
1108 caller_line: 1,
1109 callee_name: "Run".into(),
1110 },
1111 CallEdge {
1112 caller_file: "x.rs".into(),
1113 caller_symbol: "fx".into(),
1114 caller_line: 1,
1115 callee_name: "Unique".into(),
1116 },
1117 ];
1118 let map = resolve_callee_files(&inputs, &edges);
1119 assert!(!map.contains_key("Run"));
1121 assert_eq!(map.get("Unique").map(String::as_str), Some("u.rs"));
1123 }
1124
1125 #[test]
1126 fn find_enclosing_picks_narrowest() {
1127 let outer = SymbolSpan {
1128 file: "a.rs".to_string(),
1129 name: "Outer".to_string(),
1130 start_line: 1,
1131 end_line: 50,
1132 };
1133 let inner = SymbolSpan {
1134 file: "a.rs".to_string(),
1135 name: "inner_fn".to_string(),
1136 start_line: 10,
1137 end_line: 20,
1138 };
1139 let syms = vec![outer, inner];
1140 let result = find_enclosing_symbol_owned(Some(&syms), 15);
1141 assert_eq!(result, "inner_fn");
1142 }
1143
1144 #[test]
1145 fn find_enclosing_returns_module_when_no_match() {
1146 let sym = SymbolSpan {
1147 file: "a.rs".to_string(),
1148 name: "foo".to_string(),
1149 start_line: 10,
1150 end_line: 20,
1151 };
1152 let syms = vec![sym];
1153 let result = find_enclosing_symbol_owned(Some(&syms), 5);
1154 assert_eq!(result, "<module>");
1155 }
1156
1157 #[test]
1158 fn resolve_path_trims_rooted_relative_prefix() {
1159 let resolved = resolve_path(r"\src\main\kotlin\Example.kt", r"C:\repo");
1160 assert_eq!(
1161 resolved,
1162 Path::new(r"C:\repo")
1163 .join(r"src\main\kotlin\Example.kt")
1164 .to_string_lossy()
1165 .to_string()
1166 );
1167 }
1168
1169 fn build_chain_graph() -> CallGraph {
1170 let mut graph = CallGraph::new("/tmp");
1172 graph.edges.push(CallEdge {
1173 caller_file: "a.rs".into(),
1174 caller_symbol: "fn_a".into(),
1175 caller_line: 1,
1176 callee_name: "fn_b".into(),
1177 });
1178 graph.edges.push(CallEdge {
1179 caller_file: "b.rs".into(),
1180 caller_symbol: "fn_b".into(),
1181 caller_line: 10,
1182 callee_name: "fn_c".into(),
1183 });
1184 graph.edges.push(CallEdge {
1185 caller_file: "c.rs".into(),
1186 caller_symbol: "fn_c".into(),
1187 caller_line: 20,
1188 callee_name: "fn_d".into(),
1189 });
1190 graph
1191 }
1192
1193 #[test]
1194 fn bfs_callees_depth_1_returns_direct() {
1195 let graph = build_chain_graph();
1196 let nodes = graph.bfs_callees("fn_a", 1);
1197 assert_eq!(nodes.len(), 1);
1198 assert_eq!(nodes[0].symbol, "fn_b");
1199 assert_eq!(nodes[0].depth, 1);
1200 }
1201
1202 #[test]
1203 fn bfs_callees_depth_3_returns_chain() {
1204 let graph = build_chain_graph();
1205 let nodes = graph.bfs_callees("fn_a", 3);
1206 assert_eq!(nodes.len(), 3);
1207 let syms: Vec<&str> = nodes.iter().map(|n| n.symbol.as_str()).collect();
1208 assert!(syms.contains(&"fn_b"));
1209 assert!(syms.contains(&"fn_c"));
1210 assert!(syms.contains(&"fn_d"));
1211 }
1212
1213 #[test]
1214 fn bfs_callers_depth_2_returns_transitive() {
1215 let graph = build_chain_graph();
1216 let nodes = graph.bfs_callers("fn_c", 2);
1217 assert_eq!(nodes.len(), 2);
1218 let syms: Vec<&str> = nodes.iter().map(|n| n.symbol.as_str()).collect();
1219 assert!(syms.contains(&"fn_b"));
1220 assert!(syms.contains(&"fn_a"));
1221 }
1222
1223 #[test]
1224 fn find_call_path_direct() {
1225 let graph = build_chain_graph();
1226 let path = graph.find_call_path("fn_a", "fn_b");
1227 assert!(path.is_some());
1228 let hops = path.unwrap();
1229 assert_eq!(hops.len(), 2);
1230 assert_eq!(hops[0].symbol, "fn_a");
1231 assert_eq!(hops[1].symbol, "fn_b");
1232 }
1233
1234 #[test]
1235 fn find_call_path_multi_hop() {
1236 let graph = build_chain_graph();
1237 let path = graph.find_call_path("fn_a", "fn_d");
1238 assert!(path.is_some());
1239 let hops = path.unwrap();
1240 assert_eq!(hops.len(), 4);
1241 assert_eq!(hops[0].symbol, "fn_a");
1242 assert_eq!(hops[3].symbol, "fn_d");
1243 }
1244
1245 #[test]
1246 fn find_call_path_no_connection() {
1247 let graph = build_chain_graph();
1248 let path = graph.find_call_path("fn_d", "fn_a");
1249 assert!(path.is_none());
1250 }
1251
1252 #[test]
1253 fn find_call_path_same_symbol() {
1254 let graph = build_chain_graph();
1255 let path = graph.find_call_path("fn_a", "fn_a");
1256 assert!(path.is_some());
1257 assert_eq!(path.unwrap().len(), 1);
1258 }
1259
1260 #[test]
1261 fn transitive_caller_count_returns_unique() {
1262 let mut graph = CallGraph::new("/tmp");
1263 graph.edges.push(CallEdge {
1265 caller_file: "x.rs".into(),
1266 caller_symbol: "x".into(),
1267 caller_line: 1,
1268 callee_name: "target".into(),
1269 });
1270 graph.edges.push(CallEdge {
1271 caller_file: "y.rs".into(),
1272 caller_symbol: "y".into(),
1273 caller_line: 2,
1274 callee_name: "target".into(),
1275 });
1276 graph.edges.push(CallEdge {
1277 caller_file: "z.rs".into(),
1278 caller_symbol: "z".into(),
1279 caller_line: 3,
1280 callee_name: "x".into(),
1281 });
1282 assert_eq!(graph.transitive_caller_count("target", 5), 3);
1283 }
1284
1285 #[test]
1286 fn risk_level_classification() {
1287 assert_eq!(RiskLevel::from_caller_count(0), RiskLevel::Low);
1288 assert_eq!(RiskLevel::from_caller_count(1), RiskLevel::Low);
1289 assert_eq!(RiskLevel::from_caller_count(3), RiskLevel::Medium);
1290 assert_eq!(RiskLevel::from_caller_count(7), RiskLevel::High);
1291 assert_eq!(RiskLevel::from_caller_count(15), RiskLevel::Critical);
1292 }
1293
1294 #[test]
1295 fn bfs_handles_cycle_without_infinite_loop() {
1296 let mut graph = CallGraph::new("/tmp");
1297 graph.edges.push(CallEdge {
1298 caller_file: "a.rs".into(),
1299 caller_symbol: "a".into(),
1300 caller_line: 1,
1301 callee_name: "b".into(),
1302 });
1303 graph.edges.push(CallEdge {
1304 caller_file: "b.rs".into(),
1305 caller_symbol: "b".into(),
1306 caller_line: 2,
1307 callee_name: "a".into(),
1308 });
1309 let nodes = graph.bfs_callees("a", 5);
1310 assert_eq!(nodes.len(), 1);
1312 assert_eq!(nodes[0].symbol, "b");
1313 }
1314}