Skip to main content

agentstatedeveloper_cli/commands/
graph.rs

1//! `asd callers <qname>` / `asd callees <qname>` — call graph traversal.
2//!
3//! Direct callers/callees are shown by default (`--depth 1`).  Pass
4//! `--depth N` for transitive BFS expansion up to N hops.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::time::Instant;
8
9use anyhow::Result;
10use clap::Args;
11use serde_json::{Value, json};
12
13use agentstatedeveloper_core::{AsgIndexStore, Engine, IndexStore, Symbol};
14
15use crate::config::Config;
16
17// ── shared helper ────────────────────────────────────────────────────────────
18
19/// Build a `symbol_id → Symbol` lookup map.
20///
21/// Canonical implementation now lives in [`agentstatedeveloper_core::overview`]
22/// so the CLI and the `asd-mcp` server share one copy. Re-exported here because
23/// many commands reference `crate::commands::graph::build_id_map`.
24pub use agentstatedeveloper_core::build_id_map;
25
26// ── callers command ───────────────────────────────────────────────────────────
27
28#[derive(Debug, Args)]
29pub struct CallersArgs {
30    /// Fully-qualified symbol name, e.g. `MyModule.myFunc`.
31    pub qname: String,
32
33    /// Traversal depth (1 = direct callers only).
34    #[arg(long, default_value = "1")]
35    pub depth: usize,
36
37    /// Print per-phase timing to stderr.
38    #[arg(long)]
39    pub timing: bool,
40}
41
42pub fn run_callers(cfg: &Config, args: CallersArgs) -> Result<()> {
43    let t = AsdTimer::new(args.timing);
44    let mut t = t;
45    let engine = Engine::open_sqlite(&cfg.db_path)?;
46    t.phase("open_engine");
47    let index_store = AsgIndexStore::from_engine(&engine);
48
49    let symbol = index_store
50        .get_symbol_by_qname(&engine.ref_name, &args.qname)?
51        .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
52    t.phase("symbol_lookup");
53
54    let id_map = index_store.build_id_map(&engine);
55    t.phase("build_id_map");
56
57    let results = traverse(
58        &engine,
59        &index_store,
60        &symbol.symbol_id,
61        args.depth,
62        Direction::Callers,
63        &id_map,
64    );
65    t.phase("traverse");
66
67    let qid = crate::commands::brief::query_id("callers", &[&args.qname, &args.depth.to_string()]);
68    if cfg.brief {
69        let lines = crate::commands::brief::brief_call_list(&results);
70        println!(
71            "{}",
72            serde_json::to_string_pretty(&json!({
73                "callers": lines,
74                "query_id": qid,
75            }))?
76        );
77    } else {
78        println!(
79            "{}",
80            serde_json::to_string_pretty(&json!({
81                "symbol": args.qname,
82                "depth": args.depth,
83                "count": results.len(),
84                "callers": results,
85                "query_id": qid,
86            }))?
87        );
88    }
89    t.total("callers");
90    Ok(())
91}
92
93// ── callees command ───────────────────────────────────────────────────────────
94
95#[derive(Debug, Args)]
96pub struct CalleesArgs {
97    /// Fully-qualified symbol name, e.g. `MyModule.myFunc`.
98    pub qname: String,
99
100    /// Traversal depth (1 = direct callees only).
101    #[arg(long, default_value = "1")]
102    pub depth: usize,
103
104    /// Print per-phase timing to stderr.
105    #[arg(long)]
106    pub timing: bool,
107}
108
109pub fn run_callees(cfg: &Config, args: CalleesArgs) -> Result<()> {
110    let t = AsdTimer::new(args.timing);
111    let mut t = t;
112    let engine = Engine::open_sqlite(&cfg.db_path)?;
113    t.phase("open_engine");
114    let index_store = AsgIndexStore::from_engine(&engine);
115
116    let symbol = index_store
117        .get_symbol_by_qname(&engine.ref_name, &args.qname)?
118        .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
119    t.phase("symbol_lookup");
120
121    let id_map = index_store.build_id_map(&engine);
122    t.phase("build_id_map");
123
124    let results = traverse(
125        &engine,
126        &index_store,
127        &symbol.symbol_id,
128        args.depth,
129        Direction::Callees,
130        &id_map,
131    );
132    t.phase("traverse");
133
134    let qid = crate::commands::brief::query_id("callees", &[&args.qname, &args.depth.to_string()]);
135    if cfg.brief {
136        let lines = crate::commands::brief::brief_call_list(&results);
137        println!(
138            "{}",
139            serde_json::to_string_pretty(&json!({
140                "callees": lines,
141                "query_id": qid,
142            }))?
143        );
144    } else {
145        println!(
146            "{}",
147            serde_json::to_string_pretty(&json!({
148                "symbol": args.qname,
149                "depth": args.depth,
150                "count": results.len(),
151                "callees": results,
152                "query_id": qid,
153            }))?
154        );
155    }
156    t.total("callees");
157    Ok(())
158}
159
160// ── phase timer ──────────────────────────────────────────────────────────────
161
162pub(crate) struct AsdTimer {
163    start: Instant,
164    last: Instant,
165    enabled: bool,
166}
167
168impl AsdTimer {
169    pub(crate) fn new(enabled: bool) -> Self {
170        let now = Instant::now();
171        Self {
172            start: now,
173            last: now,
174            enabled,
175        }
176    }
177    pub(crate) fn phase(&mut self, name: &str) {
178        if self.enabled {
179            let now = Instant::now();
180            eprintln!(
181                "[timing] {:20} {:5.0}ms  (total {:5.0}ms)",
182                name,
183                (now - self.last).as_secs_f64() * 1000.0,
184                (now - self.start).as_secs_f64() * 1000.0,
185            );
186            self.last = now;
187        }
188    }
189    pub(crate) fn total(&self, label: &str) {
190        if self.enabled {
191            eprintln!(
192                "[timing] {:20}         total {:5.0}ms",
193                label,
194                self.start.elapsed().as_secs_f64() * 1000.0,
195            );
196        }
197    }
198}
199
200// ── BFS traversal ─────────────────────────────────────────────────────────────
201
202enum Direction {
203    Callers,
204    Callees,
205}
206
207fn traverse(
208    engine: &Engine,
209    index_store: &AsgIndexStore<'_>,
210    start_id: &str,
211    max_depth: usize,
212    direction: Direction,
213    id_map: &HashMap<String, Symbol>,
214) -> Vec<Value> {
215    let mut visited: HashSet<String> = HashSet::new();
216    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
217    let mut results: Vec<Value> = Vec::new();
218
219    visited.insert(start_id.to_string());
220    queue.push_back((start_id.to_string(), 0));
221
222    while let Some((sym_id, depth)) = queue.pop_front() {
223        if depth >= max_depth {
224            continue;
225        }
226        let neighbors = match direction {
227            Direction::Callers => index_store
228                .get_callers(&engine.ref_name, &sym_id)
229                .unwrap_or_default(),
230            Direction::Callees => index_store
231                .get_callees(&engine.ref_name, &sym_id)
232                .unwrap_or_default(),
233        };
234        for neighbor_id in neighbors {
235            if visited.contains(&neighbor_id) {
236                continue;
237            }
238            visited.insert(neighbor_id.clone());
239
240            let entry = if let Some(s) = id_map.get(&neighbor_id) {
241                json!({
242                    "qname": s.qname,
243                    "file": s.file,
244                    "line": s.start.line,
245                    "depth": depth + 1,
246                })
247            } else {
248                json!({ "symbol_id": neighbor_id, "depth": depth + 1 })
249            };
250            results.push(entry);
251
252            if depth + 1 < max_depth {
253                queue.push_back((neighbor_id, depth + 1));
254            }
255        }
256    }
257
258    results
259}