Skip to main content

lean_ctx/core/
call_graph.rs

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// ---------------------------------------------------------------------------
14// Data types
15// ---------------------------------------------------------------------------
16
17#[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/// Minimal symbol span the call-graph builder needs to attribute a call site to
33/// its enclosing symbol — backend-agnostic, decoupled from any graph store.
34#[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/// Everything the call-graph builder reads, sourced from the [`GraphProvider`]
43/// facade (PropertyGraph). Replaces the former direct `ProjectIndex`
44/// dependency (#696): the file inventory, the symbol table (for enclosing-symbol
45/// attribution) and the import/reexport adjacency (for scope-aware callee
46/// resolution). Source content itself is still read fresh from disk per file.
47#[derive(Debug, Clone, Default)]
48pub struct CallGraphInputs {
49    pub project_root: String,
50    pub file_paths: Vec<String>,
51    pub symbols: Vec<SymbolSpan>,
52    /// `(from, to)` pairs for `import`/`reexport` edges only.
53    pub import_edges: Vec<(String, String)>,
54}
55
56impl CallGraphInputs {
57    /// Open the project graph (PropertyGraph, falling back to legacy) and
58    /// materialize call-graph inputs. Returns empty inputs (rooted at
59    /// `project_root`) when no graph is available yet — matching the old
60    /// behaviour of building from an empty index.
61    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    /// Bridge for callers that already hold a freshly-scanned
72    /// [`ProjectIndex`](super::graph_index::ProjectIndex)
73    /// (repomap, dashboard coordinator) and want call-graph inputs consistent
74    /// with *that* scan rather than a possibly-lagging PropertyGraph. Removed in
75    /// #696 Phase D once those callers move to the facade/extractor wholesale.
76    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    /// Materialize the builder inputs from a [`GraphProvider`] facade.
102    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// ---------------------------------------------------------------------------
179// Background build state (singleton per process)
180// ---------------------------------------------------------------------------
181
182#[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    // -----------------------------------------------------------------------
217    // Parallel build — processes files via rayon thread pool
218    // -----------------------------------------------------------------------
219
220    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    // -----------------------------------------------------------------------
280    // Incremental parallel build — only re-analyzes changed files
281    // -----------------------------------------------------------------------
282
283    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    // -----------------------------------------------------------------------
355    // Public API: non-blocking access for the dashboard
356    // -----------------------------------------------------------------------
357
358    /// Returns the cached graph immediately, or `None` + starts a background build.
359    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        // Try serving from disk cache first
389        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    /// Returns current build status without starting anything.
454    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    /// Force-invalidate the cached result so next request triggers a rebuild.
495    pub fn invalidate() {
496        if let Ok(mut g) = global_state().lock() {
497            *g = BuildState::Idle;
498        }
499    }
500
501    // -----------------------------------------------------------------------
502    // Legacy synchronous API (kept for non-dashboard callers)
503    // -----------------------------------------------------------------------
504
505    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    // -----------------------------------------------------------------------
530    // Multi-hop BFS traversal
531    // -----------------------------------------------------------------------
532
533    /// BFS callers up to `max_depth` hops. Returns (symbol, file, line, depth) per node.
534    pub fn bfs_callers(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
535        self.bfs_traverse(symbol, max_depth, BfsDirection::Callers)
536    }
537
538    /// BFS callees up to `max_depth` hops. Returns (symbol, file, line, depth) per node.
539    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    /// Find shortest call path from `from` to `to` using BFS.
603    /// Returns None if no path exists (searched up to depth 10).
604    /// Find shortest call path from `from` to `to` using BFS.
605    /// Returns None if no path exists (searched up to depth 10).
606    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        // (parent_symbol, file, line, depth)
623        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(&current).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[&current];
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    /// Count unique transitive callers up to `max_depth`.
710    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            // Auto-migrate: compress legacy JSON to zstd
749            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
772// ---------------------------------------------------------------------------
773// Cache staleness check (fast — mtime-based, no content reads)
774// ---------------------------------------------------------------------------
775
776fn 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
787// ---------------------------------------------------------------------------
788// Helpers
789// ---------------------------------------------------------------------------
790
791fn 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
805/// Owned version for safe `Send` across rayon threads.
806fn 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
854// ---------------------------------------------------------------------------
855// Scope-aware callee resolution (#321)
856//
857// Call edges store callees as bare names, so attributing `Run`/`Get`/`Handle`
858// to a file by name alone links every same-named symbol (false positives).
859// These helpers resolve a callee to its defining file using the caller's
860// lexical scope and refuse to guess when a name stays ambiguous.
861// ---------------------------------------------------------------------------
862
863/// Build a `file -> imported files` adjacency from the project index's import
864/// and reexport edges, used to scope callee resolution to a caller's imports.
865pub 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
875/// Pick the defining file for a callee from candidate `def_files`, ranked by the
876/// caller's lexical scope (most specific first):
877///   1. the caller's own file,
878///   2. exactly one file the caller imports,
879///   3. exactly one file project-wide.
880///
881/// Returns `None` when the name stays ambiguous (never guesses).
882fn 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
907/// Resolve a single callee name to its defining file in the scope of `caller_file`.
908pub 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
925/// Resolve callee names to a single defining file *when scope makes it
926/// unambiguous across all call sites*. Names that resolve to different files
927/// from different scopes are omitted, so callers never attribute a call to the
928/// wrong file. Keyed by callee name to match the call graph's name-keyed nodes.
929pub 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        // `Run` is defined in two files (two classes). Each caller must resolve
1047        // to its *own* file, never to both.
1048        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        // A caller that neither defines nor imports `Run` stays ambiguous.
1064        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        // `main.rs` imports only `lib.rs`, so `Run` resolves there despite the
1080        // global ambiguity with `other.rs`.
1081        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        // `Run` resolves to a.rs from a and b.rs from b → two files → omitted.
1120        assert!(!map.contains_key("Run"));
1121        // `Unique` is globally unique → resolved.
1122        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        // A -> B -> C -> D
1171        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        // x -> target, y -> target, z -> x (so z is transitive caller of target)
1264        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        // Should visit b once (depth 1), then a is already visited
1311        assert_eq!(nodes.len(), 1);
1312        assert_eq!(nodes[0].symbol, "b");
1313    }
1314}