Skip to main content

aft/commands/
callgraph_store_adapter.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::callgraph::{self, TraceToSymbolCandidate};
7use crate::callgraph_store::{
8    CallGraphStore, CallGraphStoreError, StoreCallSite, StoreNode, StoreUnresolvedCall,
9};
10use crate::error::AftError;
11use crate::protocol::Response;
12
13pub type StoreAdapterResult<T> = Result<T, CallGraphStoreError>;
14
15#[derive(Debug, Clone, Default)]
16struct EdgeMarker {
17    approximate: Option<bool>,
18    resolved_by: Option<String>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct StoreCallersResult {
23    pub symbol: String,
24    pub file: String,
25    pub callers: Vec<StoreCallerGroup>,
26    pub total_callers: usize,
27    pub scanned_files: usize,
28    pub depth_limited: bool,
29    pub truncated: usize,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct StoreCallerGroup {
34    pub file: String,
35    pub callers: Vec<StoreCallerEntry>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct StoreCallerEntry {
40    pub symbol: String,
41    pub line: u32,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub approximate: Option<bool>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub resolved_by: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct StoreCallTreeNode {
50    pub name: String,
51    pub file: String,
52    pub line: u32,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub signature: Option<String>,
55    pub resolved: bool,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub approximate: Option<bool>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub resolved_by: Option<String>,
60    pub children: Vec<StoreCallTreeNode>,
61    pub depth_limited: bool,
62    pub truncated: usize,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct StoreImpactResult {
67    pub symbol: String,
68    pub file: String,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub signature: Option<String>,
71    pub parameters: Vec<String>,
72    pub total_affected: usize,
73    pub affected_files: usize,
74    pub callers: Vec<StoreImpactCaller>,
75    pub depth_limited: bool,
76    pub truncated: usize,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct StoreImpactCaller {
81    pub caller_symbol: String,
82    pub caller_file: String,
83    pub line: u32,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub signature: Option<String>,
86    pub is_entry_point: bool,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub call_expression: Option<String>,
89    pub parameters: Vec<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub approximate: Option<bool>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub resolved_by: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct StoreTraceHop {
98    pub symbol: String,
99    pub file: String,
100    pub line: u32,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub signature: Option<String>,
103    pub is_entry_point: bool,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub approximate: Option<bool>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub resolved_by: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct StoreTracePath {
112    pub hops: Vec<StoreTraceHop>,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct StoreTraceToResult {
117    pub target_symbol: String,
118    pub target_file: String,
119    pub paths: Vec<StoreTracePath>,
120    pub total_paths: usize,
121    pub entry_points_found: usize,
122    pub max_depth_reached: bool,
123    pub truncated_paths: usize,
124}
125
126#[derive(Debug, Clone, Serialize)]
127pub struct StoreTraceToSymbolHop {
128    pub symbol: String,
129    pub file: String,
130    pub line: u32,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub approximate: Option<bool>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub resolved_by: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize)]
138pub struct StoreTraceToSymbolResult {
139    pub path: Option<Vec<StoreTraceToSymbolHop>>,
140    pub complete: bool,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub reason: Option<String>,
143}
144
145enum ForwardCall {
146    Resolved(StoreCallSite),
147    Unresolved(StoreUnresolvedCall),
148}
149
150impl ForwardCall {
151    fn byte_start(&self) -> usize {
152        match self {
153            Self::Resolved(site) => site.byte_start,
154            Self::Unresolved(call) => call.byte_start,
155        }
156    }
157
158    fn line(&self) -> u32 {
159        match self {
160            Self::Resolved(site) => site.line,
161            Self::Unresolved(call) => call.line,
162        }
163    }
164
165    fn call_site_key(&self) -> (String, u32, String) {
166        match self {
167            Self::Resolved(site) => (
168                site.caller.file.clone(),
169                site.line,
170                format!("{}::{}", site.target_file, site.target_symbol),
171            ),
172            Self::Unresolved(call) => (call.caller.file.clone(), call.line, call.symbol.clone()),
173        }
174    }
175}
176
177#[derive(Clone)]
178struct ResolvedStoreSymbol {
179    representative: StoreNode,
180    nodes: Vec<StoreNode>,
181}
182
183#[derive(Clone)]
184struct TraceElem {
185    node: StoreNode,
186    edge: EdgeMarker,
187}
188
189fn edge_marker(site: &StoreCallSite) -> EdgeMarker {
190    if let Some(resolved_by) = site.supplemental_resolution() {
191        EdgeMarker {
192            approximate: Some(site.approximate()),
193            resolved_by: Some(resolved_by.to_string()),
194        }
195    } else {
196        EdgeMarker::default()
197    }
198}
199
200fn edge_approximate(site: &StoreCallSite) -> Option<bool> {
201    site.supplemental_resolution().map(|_| site.approximate())
202}
203
204fn edge_resolved_by(site: &StoreCallSite) -> Option<String> {
205    site.supplemental_resolution().map(ToString::to_string)
206}
207
208pub fn callers_result(
209    store: &CallGraphStore,
210    file: &Path,
211    symbol: &str,
212    depth: usize,
213) -> StoreAdapterResult<StoreCallersResult> {
214    let target = resolve_symbol_query(store, file, symbol)?;
215    let effective_depth = depth.max(1);
216    let mut visited = HashSet::new();
217    let mut sites = Vec::new();
218    let mut depth_limited = false;
219    let mut truncated = 0usize;
220
221    collect_callers_recursive(
222        store,
223        &target.representative.file,
224        &target.representative.symbol,
225        effective_depth,
226        0,
227        &mut visited,
228        &mut sites,
229        &mut depth_limited,
230        &mut truncated,
231    )?;
232
233    let sites = dedup_call_sites(sites);
234    let total_callers = sites.len();
235    let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
236    for site in sites {
237        groups
238            .entry(site.caller.file.clone())
239            .or_default()
240            .push(StoreCallerEntry {
241                symbol: site.caller.symbol.clone(),
242                line: site.line,
243                approximate: edge_approximate(&site),
244                resolved_by: edge_resolved_by(&site),
245            });
246    }
247
248    Ok(StoreCallersResult {
249        symbol: target.representative.symbol,
250        file: target.representative.file,
251        callers: groups
252            .into_iter()
253            .map(|(file, callers)| StoreCallerGroup { file, callers })
254            .collect(),
255        total_callers,
256        scanned_files: store.indexed_file_count()?,
257        depth_limited,
258        truncated,
259    })
260}
261
262pub fn call_tree_result(
263    store: &CallGraphStore,
264    file: &Path,
265    symbol: &str,
266    depth: usize,
267) -> StoreAdapterResult<StoreCallTreeNode> {
268    let target = resolve_symbol_query(store, file, symbol)?;
269    let mut visited = HashSet::new();
270    call_tree_inner(store, &target, depth, 0, &mut visited)
271}
272
273pub fn impact_result(
274    store: &CallGraphStore,
275    file: &Path,
276    symbol: &str,
277    depth: usize,
278) -> StoreAdapterResult<StoreImpactResult> {
279    let target = resolve_symbol_query(store, file, symbol)?;
280    let effective_depth = depth.max(1);
281    let mut visited = HashSet::new();
282    let mut sites = Vec::new();
283    let mut depth_limited = false;
284    let mut truncated = 0usize;
285
286    collect_callers_recursive(
287        store,
288        &target.representative.file,
289        &target.representative.symbol,
290        effective_depth,
291        0,
292        &mut visited,
293        &mut sites,
294        &mut depth_limited,
295        &mut truncated,
296    )?;
297
298    let sites = dedup_call_sites(sites);
299    let target_signature = target.representative.signature.clone();
300    let target_parameters = target_signature
301        .as_deref()
302        .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
303        .unwrap_or_default();
304
305    let mut affected_files = BTreeSet::new();
306    let mut callers = Vec::new();
307    for site in sites {
308        affected_files.insert(site.caller.file.clone());
309        callers.push(StoreImpactCaller {
310            caller_symbol: site.caller.symbol.clone(),
311            caller_file: site.caller.file.clone(),
312            line: site.line,
313            signature: site.caller.signature.clone(),
314            is_entry_point: site.caller.is_entry_point,
315            call_expression: read_source_line(
316                &store.project_root().join(&site.caller.file),
317                site.line,
318            ),
319            parameters: site
320                .caller
321                .signature
322                .as_deref()
323                .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
324                .unwrap_or_default(),
325            approximate: edge_approximate(&site),
326            resolved_by: edge_resolved_by(&site),
327        });
328    }
329    callers.sort_by(|left, right| {
330        left.caller_file
331            .cmp(&right.caller_file)
332            .then(left.line.cmp(&right.line))
333    });
334
335    Ok(StoreImpactResult {
336        symbol: target.representative.symbol,
337        file: target.representative.file,
338        signature: target_signature,
339        parameters: target_parameters,
340        total_affected: callers.len(),
341        affected_files: affected_files.len(),
342        callers,
343        depth_limited,
344        truncated,
345    })
346}
347
348pub fn trace_to_result(
349    store: &CallGraphStore,
350    file: &Path,
351    symbol: &str,
352    max_depth: usize,
353) -> StoreAdapterResult<StoreTraceToResult> {
354    let target = resolve_symbol_query(store, file, symbol)?;
355    let effective_max = if max_depth == 0 { 10 } else { max_depth };
356
357    let initial = vec![TraceElem {
358        node: target.representative.clone(),
359        edge: EdgeMarker::default(),
360    }];
361    let mut complete_paths = Vec::new();
362    if target.representative.is_entry_point {
363        complete_paths.push(initial.clone());
364    }
365
366    let mut queue = vec![(initial, 0usize)];
367    let mut max_depth_reached = false;
368    let mut truncated_paths = 0usize;
369
370    while let Some((path, depth)) = queue.pop() {
371        if depth >= effective_max {
372            max_depth_reached = true;
373            continue;
374        }
375        let Some(current) = path.last() else {
376            continue;
377        };
378        let callers = dedup_call_sites(
379            store.direct_callers_of(Path::new(&current.node.file), &current.node.symbol)?,
380        );
381        if callers.is_empty() {
382            if path.len() > 1 {
383                truncated_paths += 1;
384            }
385            continue;
386        }
387
388        let mut has_new_path = false;
389        for site in callers {
390            if path.iter().any(|elem| {
391                elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
392            }) {
393                continue;
394            }
395            has_new_path = true;
396            let mut next_path = path.clone();
397            if let Some(current) = next_path.last_mut() {
398                current.edge = edge_marker(&site);
399            }
400            next_path.push(TraceElem {
401                node: site.caller.clone(),
402                edge: EdgeMarker::default(),
403            });
404            if site.caller.is_entry_point {
405                complete_paths.push(next_path.clone());
406            }
407            queue.push((next_path, depth + 1));
408        }
409        if !has_new_path && path.len() > 1 {
410            truncated_paths += 1;
411        }
412    }
413
414    let mut paths: Vec<StoreTracePath> = complete_paths
415        .into_iter()
416        .map(|mut elems| {
417            elems.reverse();
418            let hops = elems
419                .iter()
420                .enumerate()
421                .map(|(index, elem)| StoreTraceHop {
422                    symbol: elem.node.symbol.clone(),
423                    file: elem.node.file.clone(),
424                    line: elem.node.line,
425                    signature: elem.node.signature.clone(),
426                    is_entry_point: index == 0 && elem.node.is_entry_point,
427                    approximate: elem.edge.approximate,
428                    resolved_by: elem.edge.resolved_by.clone(),
429                })
430                .collect();
431            StoreTracePath { hops }
432        })
433        .collect();
434    paths.sort_by(|left, right| {
435        let left_entry = left
436            .hops
437            .first()
438            .map(|hop| hop.symbol.as_str())
439            .unwrap_or("");
440        let right_entry = right
441            .hops
442            .first()
443            .map(|hop| hop.symbol.as_str())
444            .unwrap_or("");
445        left_entry
446            .cmp(right_entry)
447            .then(left.hops.len().cmp(&right.hops.len()))
448    });
449    let entry_points_found = paths
450        .iter()
451        .filter_map(|path| path.hops.first())
452        .filter(|hop| hop.is_entry_point)
453        .map(|hop| (hop.file.clone(), hop.symbol.clone()))
454        .collect::<HashSet<_>>()
455        .len();
456
457    Ok(StoreTraceToResult {
458        target_symbol: target.representative.symbol,
459        target_file: target.representative.file,
460        total_paths: paths.len(),
461        paths,
462        entry_points_found,
463        max_depth_reached,
464        truncated_paths,
465    })
466}
467
468pub fn ensure_symbol_resolves(
469    store: &CallGraphStore,
470    file: &Path,
471    symbol: &str,
472) -> StoreAdapterResult<()> {
473    resolve_symbol_query(store, file, symbol).map(|_| ())
474}
475
476pub fn trace_to_symbol_candidates(
477    store: &CallGraphStore,
478    to_symbol: &str,
479) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
480    store.trace_to_symbol_candidates(to_symbol)
481}
482
483pub fn trace_to_symbol_result(
484    store: &CallGraphStore,
485    file: &Path,
486    symbol: &str,
487    to_symbol: &str,
488    to_file: Option<&Path>,
489    max_depth: usize,
490) -> StoreAdapterResult<StoreTraceToSymbolResult> {
491    let origin = resolve_symbol_query(store, file, symbol)?;
492    let target_file = to_file.map(|path| relative_file(store, path));
493    let effective_max = if max_depth == 0 {
494        10
495    } else {
496        max_depth.min(16)
497    };
498
499    let start_hop = trace_to_symbol_hop(&origin.representative);
500    if trace_to_symbol_matches_target(
501        &origin.representative.file,
502        &origin.representative.symbol,
503        to_symbol,
504        target_file.as_deref(),
505    ) {
506        return Ok(StoreTraceToSymbolResult {
507            path: Some(vec![start_hop]),
508            complete: true,
509            reason: None,
510        });
511    }
512
513    let mut queue = VecDeque::new();
514    queue.push_back((
515        origin.representative.file.clone(),
516        origin.representative.symbol.clone(),
517        vec![start_hop],
518        0usize,
519    ));
520    let mut visited = HashSet::new();
521    visited.insert((
522        origin.representative.file.clone(),
523        origin.representative.symbol.clone(),
524    ));
525    let mut max_depth_exhausted = false;
526
527    while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
528        let callees = forward_resolved_callees(store, &current_file, &current_symbol)?;
529
530        if depth >= effective_max {
531            if callees
532                .iter()
533                .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
534            {
535                max_depth_exhausted = true;
536            }
537            continue;
538        }
539
540        for (callee, edge) in callees {
541            if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
542                continue;
543            }
544            let mut next_path = path.clone();
545            next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
546            if trace_to_symbol_matches_target(
547                &callee.file,
548                &callee.symbol,
549                to_symbol,
550                target_file.as_deref(),
551            ) {
552                return Ok(StoreTraceToSymbolResult {
553                    path: Some(next_path),
554                    complete: true,
555                    reason: None,
556                });
557            }
558            queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
559        }
560    }
561
562    if max_depth_exhausted {
563        Ok(StoreTraceToSymbolResult {
564            path: None,
565            complete: false,
566            reason: Some("max_depth_exhausted".to_string()),
567        })
568    } else {
569        Ok(StoreTraceToSymbolResult {
570            path: None,
571            complete: true,
572            reason: Some("no_path_found".to_string()),
573        })
574    }
575}
576
577pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
578    match error {
579        CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
580        CallGraphStoreError::Unavailable(message) => Response::error(
581            req_id,
582            "callgraph_unavailable",
583            format!("{operation}: persisted callgraph store unavailable: {message}"),
584        ),
585        CallGraphStoreError::StaleFiles(files) => Response::error(
586            req_id,
587            "callgraph_stale",
588            format!(
589                "{operation}: persisted callgraph store has stale files: {}",
590                files.join(", ")
591            ),
592        ),
593        other => Response::error(
594            req_id,
595            "callgraph_store_error",
596            format!("{operation}: persisted callgraph store error: {other}"),
597        ),
598    }
599}
600
601/// The persisted callgraph store is cold-building in the background. The op did
602/// not block the request thread; the agent should retry shortly. Mirrors how
603/// semantic search reports a build in progress.
604pub fn building_response(req_id: &str, operation: &str) -> Response {
605    Response::error(
606        req_id,
607        "callgraph_building",
608        format!("{operation}: callgraph store is building in the background; retry shortly"),
609    )
610}
611
612pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
613    let message = if worktree {
614        format!(
615            "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
616        )
617    } else {
618        format!("{operation}: project not configured — send 'configure' first")
619    };
620    let code = if worktree {
621        "callgraph_unavailable"
622    } else {
623        "not_configured"
624    };
625    Response::error(req_id, code, message)
626}
627
628fn resolve_symbol_query(
629    store: &CallGraphStore,
630    file: &Path,
631    symbol: &str,
632) -> StoreAdapterResult<ResolvedStoreSymbol> {
633    let nodes = store.nodes_for(file, symbol)?;
634    collapse_symbol_nodes(store, file, symbol, nodes)
635}
636
637fn resolve_exact_symbol(
638    store: &CallGraphStore,
639    file: &str,
640    symbol: &str,
641    fallback: Option<StoreNode>,
642) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
643    let nodes = store
644        .nodes_for(Path::new(file), symbol)?
645        .into_iter()
646        .filter(|node| node.symbol == symbol)
647        .collect::<Vec<_>>();
648    if nodes.is_empty() {
649        return Ok(fallback.map(|node| ResolvedStoreSymbol {
650            representative: node.clone(),
651            nodes: vec![node],
652        }));
653    }
654    Ok(Some(collapse_exact_nodes(nodes)))
655}
656
657fn collapse_symbol_nodes(
658    store: &CallGraphStore,
659    file: &Path,
660    query: &str,
661    nodes: Vec<StoreNode>,
662) -> StoreAdapterResult<ResolvedStoreSymbol> {
663    let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
664    for node in nodes {
665        by_symbol.entry(node.symbol.clone()).or_default().push(node);
666    }
667
668    match by_symbol.len() {
669        0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
670            name: query.to_string(),
671            file: display_file_for_error(store, file),
672        })),
673        1 => Ok(collapse_exact_nodes(
674            by_symbol.into_values().next().unwrap_or_default(),
675        )),
676        _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
677            name: query.to_string(),
678            candidates: by_symbol.into_keys().collect(),
679        })),
680    }
681}
682
683fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
684    nodes.sort_by(|left, right| {
685        left.symbol
686            .cmp(&right.symbol)
687            .then(left.line.cmp(&right.line))
688            .then(left.end_line.cmp(&right.end_line))
689    });
690    let representative = nodes[0].clone();
691    ResolvedStoreSymbol {
692        representative,
693        nodes,
694    }
695}
696
697#[allow(clippy::too_many_arguments)]
698fn collect_callers_recursive(
699    store: &CallGraphStore,
700    file: &str,
701    symbol: &str,
702    max_depth: usize,
703    current_depth: usize,
704    visited: &mut HashSet<(String, String)>,
705    result: &mut Vec<StoreCallSite>,
706    depth_limited: &mut bool,
707    truncated: &mut usize,
708) -> StoreAdapterResult<()> {
709    if current_depth >= max_depth {
710        let omitted = dedup_call_site_count(store.direct_callers_of(Path::new(file), symbol)?);
711        if omitted > 0 {
712            *depth_limited = true;
713            *truncated += omitted;
714        }
715        return Ok(());
716    }
717
718    if !visited.insert((file.to_string(), symbol.to_string())) {
719        return Ok(());
720    }
721
722    let sites = store.direct_callers_of(Path::new(file), symbol)?;
723    for site in sites {
724        result.push(site.clone());
725        if current_depth + 1 < max_depth {
726            collect_callers_recursive(
727                store,
728                &site.caller.file,
729                &site.caller.symbol,
730                max_depth,
731                current_depth + 1,
732                visited,
733                result,
734                depth_limited,
735                truncated,
736            )?;
737        } else {
738            let omitted = dedup_call_site_count(
739                store.direct_callers_of(Path::new(&site.caller.file), &site.caller.symbol)?,
740            );
741            if omitted > 0 {
742                *depth_limited = true;
743                *truncated += omitted;
744            }
745        }
746    }
747    Ok(())
748}
749
750fn call_tree_inner(
751    store: &CallGraphStore,
752    current: &ResolvedStoreSymbol,
753    max_depth: usize,
754    current_depth: usize,
755    visited: &mut HashSet<(String, String)>,
756) -> StoreAdapterResult<StoreCallTreeNode> {
757    let node = &current.representative;
758    let visit_key = (node.file.clone(), node.symbol.clone());
759    if visited.contains(&visit_key) {
760        return Ok(StoreCallTreeNode {
761            name: node.symbol.clone(),
762            file: node.file.clone(),
763            line: node.line,
764            signature: node.signature.clone(),
765            resolved: true,
766            approximate: None,
767            resolved_by: None,
768            children: Vec::new(),
769            depth_limited: false,
770            truncated: 0,
771        });
772    }
773    visited.insert(visit_key.clone());
774
775    let calls = forward_calls_for_nodes(store, &current.nodes)?;
776    let mut children = Vec::new();
777    let mut depth_limited = false;
778    let mut truncated = 0usize;
779
780    if current_depth < max_depth {
781        for call in calls {
782            match call {
783                ForwardCall::Resolved(site) => {
784                    let resolved = resolve_exact_symbol(
785                        store,
786                        &site.target_file,
787                        &site.target_symbol,
788                        site.target.clone(),
789                    )?;
790                    if let Some(child_symbol) = resolved {
791                        let mut child = call_tree_inner(
792                            store,
793                            &child_symbol,
794                            max_depth,
795                            current_depth + 1,
796                            visited,
797                        )?;
798                        child.approximate = edge_approximate(&site);
799                        child.resolved_by = edge_resolved_by(&site);
800                        depth_limited |= child.depth_limited;
801                        truncated += child.truncated;
802                        children.push(child);
803                    } else {
804                        children.push(StoreCallTreeNode {
805                            name: site.target_symbol.clone(),
806                            file: site.target_file.clone(),
807                            line: site.line,
808                            signature: None,
809                            resolved: false,
810                            approximate: edge_approximate(&site),
811                            resolved_by: edge_resolved_by(&site),
812                            children: Vec::new(),
813                            depth_limited: false,
814                            truncated: 0,
815                        });
816                    }
817                }
818                ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
819                    name: call.symbol,
820                    file: call.caller.file,
821                    line: call.line,
822                    signature: None,
823                    resolved: false,
824                    approximate: None,
825                    resolved_by: None,
826                    children: Vec::new(),
827                    depth_limited: false,
828                    truncated: 0,
829                }),
830            }
831        }
832    } else if !calls.is_empty() {
833        depth_limited = true;
834        truncated = calls.len();
835    }
836
837    visited.remove(&visit_key);
838    Ok(StoreCallTreeNode {
839        name: node.symbol.clone(),
840        file: node.file.clone(),
841        line: node.line,
842        signature: node.signature.clone(),
843        resolved: true,
844        approximate: None,
845        resolved_by: None,
846        children,
847        depth_limited,
848        truncated,
849    })
850}
851
852fn forward_calls_for_nodes(
853    store: &CallGraphStore,
854    nodes: &[StoreNode],
855) -> StoreAdapterResult<Vec<ForwardCall>> {
856    let mut calls = Vec::new();
857    for node in nodes {
858        calls.extend(
859            store
860                .outgoing_calls_of(node)?
861                .into_iter()
862                .map(ForwardCall::Resolved),
863        );
864        calls.extend(
865            store
866                .unresolved_calls_of(node)?
867                .into_iter()
868                .map(ForwardCall::Unresolved),
869        );
870    }
871    calls.sort_by(|left, right| {
872        left.byte_start()
873            .cmp(&right.byte_start())
874            .then(left.line().cmp(&right.line()))
875    });
876    let mut seen = BTreeSet::new();
877    calls.retain(|call| seen.insert(call.call_site_key()));
878    Ok(calls)
879}
880
881fn forward_resolved_callees(
882    store: &CallGraphStore,
883    file: &str,
884    symbol: &str,
885) -> StoreAdapterResult<Vec<(StoreNode, EdgeMarker)>> {
886    let Some(current) = resolve_exact_symbol(store, file, symbol, None)? else {
887        return Ok(Vec::new());
888    };
889    let mut calls = Vec::new();
890    for node in &current.nodes {
891        calls.extend(store.outgoing_calls_of(node)?);
892    }
893    calls = dedup_call_sites(calls);
894    calls.sort_by(|left, right| {
895        left.byte_start
896            .cmp(&right.byte_start)
897            .then(left.line.cmp(&right.line))
898    });
899
900    let mut callees = Vec::new();
901    for site in calls {
902        let resolved = resolve_exact_symbol(
903            store,
904            &site.target_file,
905            &site.target_symbol,
906            site.target.clone(),
907        )?;
908        if let Some(target) = resolved {
909            callees.push((target.representative, edge_marker(&site)));
910        }
911    }
912    Ok(callees)
913}
914
915fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
916    let mut seen = HashSet::new();
917    let mut deduped = Vec::new();
918    for site in sites {
919        if seen.insert(call_site_key(&site)) {
920            deduped.push(site);
921        }
922    }
923    deduped
924}
925
926fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
927    sites
928        .into_iter()
929        .map(|site| call_site_key(&site))
930        .collect::<HashSet<_>>()
931        .len()
932}
933
934fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
935    (
936        site.caller.file.clone(),
937        site.line,
938        site.target_file.clone(),
939        site.target_symbol.clone(),
940    )
941}
942
943fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
944    trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
945}
946
947fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
948    StoreTraceToSymbolHop {
949        symbol: node.symbol.clone(),
950        file: node.file.clone(),
951        line: node.line,
952        approximate: edge.approximate,
953        resolved_by: edge.resolved_by,
954    }
955}
956
957fn trace_to_symbol_matches_target(
958    file: &str,
959    symbol: &str,
960    to_symbol: &str,
961    to_file: Option<&str>,
962) -> bool {
963    if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
964        return false;
965    }
966    match to_file {
967        Some(target_file) => file == target_file,
968        None => true,
969    }
970}
971
972fn unqualified_name(symbol: &str) -> &str {
973    symbol.rsplit("::").next().unwrap_or(symbol)
974}
975
976fn read_source_line(path: &Path, line: u32) -> Option<String> {
977    let source = std::fs::read_to_string(path).ok()?;
978    source
979        .lines()
980        .nth(line.saturating_sub(1) as usize)
981        .map(|line| line.trim().to_string())
982}
983
984fn display_file_for_error(store: &CallGraphStore, file: &Path) -> String {
985    absolute_file(store, file).display().to_string()
986}
987
988fn relative_file(store: &CallGraphStore, file: &Path) -> String {
989    let absolute = absolute_file(store, file);
990    absolute
991        .strip_prefix(store.project_root())
992        .unwrap_or(&absolute)
993        .to_string_lossy()
994        .replace('\\', "/")
995}
996
997fn absolute_file(store: &CallGraphStore, file: &Path) -> PathBuf {
998    let full_path = if file.is_relative() {
999        store.project_root().join(file)
1000    } else {
1001        file.to_path_buf()
1002    };
1003    std::fs::canonicalize(&full_path).unwrap_or(full_path)
1004}