Skip to main content

agentstatedeveloper_core/
overview.rs

1//! Read-only "orient me" reports shared by the CLI and the `asd-mcp` server.
2//!
3//! These build the same JSON the `asd architecture`, `asd dead-code`, and
4//! `asd endpoints` commands emit in `--agent` mode. Hoisting them into core
5//! means the CLI and the MCP tools return byte-identical payloads and can
6//! never drift. The logic was moved verbatim from the CLI command modules.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9
10use serde_json::{Value, json};
11
12use crate::{
13    Direction, Engine, Symbol, SymbolKind, classify_layer_sym, detect_communities,
14    endpoints_from_tree, load_layer_overrides, match_edges, resolve_repo_id, symbol_tier,
15};
16
17/// `id → Symbol` map for the current ref. Fast path via the SQLite symbol
18/// cache, authoritative git fallback. Shared by the many commands that need to
19/// resolve call-graph node ids back to symbols.
20pub fn build_id_map(engine: &Engine) -> HashMap<String, Symbol> {
21    // Fast path: SQLite symbol cache — reuse engine's already-open connection.
22    if let Some(fts) = engine.fts.as_ref() {
23        if fts.symbols_cached_for(&engine.ref_name) {
24            let map = fts.build_id_map_cached(&engine.ref_name);
25            if !map.is_empty() {
26                return map;
27            }
28        }
29    }
30    // Authoritative git fallback.
31    let tree = engine
32        .repo
33        .get_tree(&engine.ref_name, "/asd/v1/index/by-qname")
34        .unwrap_or(Value::Object(Default::default()));
35    tree.as_object()
36        .map(|m| {
37            m.values()
38                .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
39                .map(|s| (s.symbol_id.clone(), s))
40                .collect()
41        })
42        .unwrap_or_default()
43}
44
45// ── architecture ───────────────────────────────────────────────────────────
46
47/// One-call "orient me" overview: languages, packages, layers, call-graph
48/// communities (clusters), inbound/outbound routes, and hotspots. This is the
49/// JSON body `asd architecture --agent` prints.
50pub fn architecture_overview(engine: &Engine, top: usize) -> Value {
51    let id_map = build_id_map(engine);
52    let db_path = engine.db_path.clone().unwrap_or_default();
53    let overrides = load_layer_overrides(&db_path);
54
55    // Languages, packages, layers — one pass over all symbols.
56    let mut languages: HashMap<String, usize> = HashMap::new();
57    let mut packages: HashMap<String, usize> = HashMap::new();
58    let mut layers: BTreeMap<&'static str, usize> = BTreeMap::new();
59    for s in id_map.values() {
60        *languages.entry(s.language.clone()).or_default() += 1;
61        *packages.entry(package_of(&s.file)).or_default() += 1;
62        let layer = classify_layer_sym(&s.file, &s.qname, symbol_tier(&s.file), &overrides);
63        *layers.entry(layer).or_default() += 1;
64    }
65
66    // Hotspots: call-graph degree = |callers| + |callees|.
67    let degree = symbol_degree(engine);
68    let mut hot: Vec<(&String, usize)> = degree.iter().map(|(k, v)| (k, *v)).collect();
69    hot.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
70    let hotspots: Vec<Value> = hot
71        .iter()
72        .take(top)
73        .filter_map(|(id, deg)| {
74            id_map
75                .get(*id)
76                .map(|s| json!({ "qname": s.qname, "file": s.file, "degree": deg }))
77        })
78        .collect();
79
80    // Routes: inbound endpoints this repo serves (+ outbound consumer count).
81    let ep_tree = engine
82        .repo
83        .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
84        .unwrap_or(Value::Null);
85    let endpoints = endpoints_from_tree(&ep_tree);
86    let mut inbound: Vec<Value> = endpoints
87        .iter()
88        .filter(|e| e.direction == Direction::Inbound)
89        .map(|e| json!({ "contract": e.contract, "qname": e.qname }))
90        .collect();
91    inbound.sort_by(|a, b| a["contract"].as_str().cmp(&b["contract"].as_str()));
92    let outbound_count = endpoints
93        .iter()
94        .filter(|e| e.direction == Direction::Outbound)
95        .count();
96
97    // Functional clusters: call-graph communities (Louvain local-move), labeled
98    // by their dominant package + highest-degree representative.
99    let clusters = compute_clusters(engine, &id_map, &degree, top);
100
101    let repo_id = resolve_repo_id(std::env::var("ASD_REPO_ID").ok().as_deref(), &cwd());
102
103    json!({
104        "repo_id": repo_id,
105        "symbols": id_map.len(),
106        "languages": sorted_counts(languages, usize::MAX),
107        "packages": sorted_counts(packages, top),
108        "layers": layers.iter().map(|(k, v)| json!({ "layer": k, "symbols": v })).collect::<Vec<_>>(),
109        "clusters": clusters,
110        "routes": { "inbound": inbound, "outbound_consumers": outbound_count },
111        "hotspots": hotspots,
112        "note": "clusters are call-graph communities (functional groups that call each other); layers are the orthogonal path-based view",
113    })
114}
115
116/// Parent directory of a file, used as a coarse package/module grouping.
117fn package_of(file: &str) -> String {
118    match file.rfind('/') {
119        Some(i) => file[..i].to_string(),
120        None => ".".to_string(),
121    }
122}
123
124/// Call-graph degree per symbol_id from the callers/callees registry trees.
125fn symbol_degree(engine: &Engine) -> HashMap<String, usize> {
126    let mut degree: HashMap<String, usize> = HashMap::new();
127    for (path, key) in [
128        ("/asd/v1/index/callees", "callees"),
129        ("/asd/v1/index/callers", "callers"),
130    ] {
131        let tree = engine
132            .repo
133            .get_tree(&engine.ref_name, path)
134            .unwrap_or(Value::Null);
135        if let Some(obj) = tree.as_object() {
136            for (sym_id, v) in obj {
137                let n = v.get(key).and_then(|x| x.as_array()).map_or(0, |a| a.len());
138                *degree.entry(sym_id.clone()).or_default() += n;
139            }
140        }
141    }
142    degree
143}
144
145/// Functional clusters = call-graph communities, each labeled by its dominant
146/// package and highest-degree representative symbol. Singletons are dropped.
147fn compute_clusters(
148    engine: &Engine,
149    id_map: &HashMap<String, Symbol>,
150    degree: &HashMap<String, usize>,
151    top: usize,
152) -> Vec<Value> {
153    let tree = engine
154        .repo
155        .get_tree(&engine.ref_name, "/asd/v1/index/callees")
156        .unwrap_or(Value::Null);
157    let mut edges: Vec<(String, String)> = Vec::new();
158    let mut node_set: BTreeSet<String> = BTreeSet::new();
159    if let Some(obj) = tree.as_object() {
160        for (sym, v) in obj {
161            if let Some(arr) = v.get("callees").and_then(|a| a.as_array()) {
162                for c in arr.iter().filter_map(|c| c.as_str()) {
163                    node_set.insert(sym.clone());
164                    node_set.insert(c.to_string());
165                    edges.push((sym.clone(), c.to_string()));
166                }
167            }
168        }
169    }
170    if edges.is_empty() {
171        return Vec::new();
172    }
173    let nodes: Vec<String> = node_set.into_iter().collect();
174    let comm = detect_communities(&nodes, &edges);
175
176    let mut groups: HashMap<usize, Vec<&String>> = HashMap::new();
177    for (sym, c) in &comm {
178        groups.entry(*c).or_default().push(sym);
179    }
180
181    let mut clusters: Vec<(usize, Value)> = groups
182        .into_values()
183        .filter(|members| members.len() >= 2)
184        .map(|members| {
185            // Dominant package among members (ties → lexicographically smaller).
186            let mut pkg: HashMap<String, usize> = HashMap::new();
187            for m in &members {
188                if let Some(s) = id_map.get(*m) {
189                    *pkg.entry(package_of(&s.file)).or_default() += 1;
190                }
191            }
192            let package = pkg
193                .into_iter()
194                .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
195                .map(|(p, _)| p)
196                .unwrap_or_default();
197            // Representative = highest-degree member.
198            let representative = members
199                .iter()
200                .filter_map(|m| {
201                    id_map.get(*m).map(|s| (degree.get(*m).copied().unwrap_or(0), s.qname.clone()))
202                })
203                .max_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)))
204                .map(|(_, q)| q)
205                .unwrap_or_default();
206            (
207                members.len(),
208                json!({ "size": members.len(), "package": package, "representative": representative }),
209            )
210        })
211        .collect();
212    clusters.sort_by(|a, b| b.0.cmp(&a.0));
213    clusters.into_iter().take(top).map(|(_, v)| v).collect()
214}
215
216/// `[{name, symbols}]` sorted by count desc then name, truncated to `limit`.
217fn sorted_counts(map: HashMap<String, usize>, limit: usize) -> Vec<Value> {
218    let mut v: Vec<(String, usize)> = map.into_iter().collect();
219    v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
220    v.into_iter()
221        .take(limit)
222        .map(|(name, n)| json!({ "name": name, "symbols": n }))
223        .collect()
224}
225
226fn cwd() -> std::path::PathBuf {
227    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
228}
229
230// ── dead code ──────────────────────────────────────────────────────────────
231
232/// Functions/methods with no inbound call edges — a *candidate* list. Excludes
233/// HTTP route handlers, test functions (unless `include_tests`), and
234/// `main`/dunder methods. This is the JSON body `asd dead-code --agent` prints.
235pub fn dead_code_report(engine: &Engine, limit: usize, include_tests: bool) -> Value {
236    let id_map = build_id_map(engine);
237
238    // Symbols that have at least one inbound caller.
239    let callers_tree = engine
240        .repo
241        .get_tree(&engine.ref_name, "/asd/v1/index/callers")
242        .unwrap_or(Value::Null);
243    let mut has_callers: HashSet<String> = HashSet::new();
244    if let Some(obj) = callers_tree.as_object() {
245        for (sym_id, v) in obj {
246            let n = v
247                .get("callers")
248                .and_then(|a| a.as_array())
249                .map_or(0, |a| a.len());
250            if n > 0 {
251                has_callers.insert(sym_id.clone());
252            }
253        }
254    }
255
256    // Inbound endpoint owners are reachable over HTTP without a static caller.
257    let ep_tree = engine
258        .repo
259        .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
260        .unwrap_or(Value::Null);
261    let handler_syms: HashSet<String> = endpoints_from_tree(&ep_tree)
262        .into_iter()
263        .filter(|e| e.direction == Direction::Inbound)
264        .map(|e| e.symbol_id)
265        .collect();
266
267    let mut excluded_handlers = 0usize;
268    let mut excluded_tests = 0usize;
269    let mut dead: Vec<&Symbol> = Vec::new();
270    for (sym_id, sym) in &id_map {
271        if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
272            continue;
273        }
274        if has_callers.contains(sym_id) {
275            continue;
276        }
277        if is_runtime_entry(&sym.qname) {
278            continue;
279        }
280        if handler_syms.contains(sym_id) {
281            excluded_handlers += 1;
282            continue;
283        }
284        if !include_tests && is_test_symbol(&sym.file, &sym.qname) {
285            excluded_tests += 1;
286            continue;
287        }
288        dead.push(sym);
289    }
290    dead.sort_by(|a, b| {
291        a.file
292            .cmp(&b.file)
293            .then_with(|| a.start.line.cmp(&b.start.line))
294    });
295
296    let total = dead.len();
297    let candidates: Vec<Value> = dead
298        .iter()
299        .take(limit)
300        .map(|s| {
301            json!({
302                "qname": s.qname,
303                "file": s.file,
304                "line": s.start.line,
305                "kind": dc_kind_str(s.kind),
306            })
307        })
308        .collect();
309
310    let note = "Functions/methods with no inbound call edges in the index. NOT definitive — \
311                the static call graph misses public API used by other repos, dynamic dispatch \
312                (reflection, callbacks, trait/interface impls), framework-invoked methods, and \
313                calls the resolver couldn't bind (see `dropped_call_edges` in `asd index`). \
314                Route handlers, test functions, and main/dunder methods are excluded.";
315
316    json!({
317        "count": total,
318        "shown": candidates.len(),
319        "excluded": { "route_handlers": excluded_handlers, "test_symbols": excluded_tests },
320        "note": note,
321        "candidates": candidates,
322    })
323}
324
325/// Runtime-reachable by name even without a static caller: program entry points
326/// and dunder methods the language runtime invokes implicitly.
327fn is_runtime_entry(qname: &str) -> bool {
328    let last = qname.rsplit(['.', ':']).next().unwrap_or(qname);
329    last == "main" || (last.starts_with("__") && last.ends_with("__"))
330}
331
332/// A test symbol by file path (tier 2) OR by an inline test module in its qname.
333fn is_test_symbol(file: &str, qname: &str) -> bool {
334    symbol_tier(file) == 2 || qname.split(['.', ':']).any(|c| c == "tests" || c == "test")
335}
336
337fn dc_kind_str(k: SymbolKind) -> &'static str {
338    match k {
339        SymbolKind::Function => "function",
340        SymbolKind::Method => "method",
341        SymbolKind::Class => "class",
342        SymbolKind::Module => "module",
343        SymbolKind::Variable => "variable",
344    }
345}
346
347// ── endpoints ──────────────────────────────────────────────────────────────
348
349/// Cross-service endpoints detected in this repo plus the in-repo matched
350/// edges. This is the JSON body `asd endpoints --agent` prints.
351pub fn endpoints_report(engine: &Engine) -> Value {
352    let tree = engine
353        .repo
354        .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
355        .unwrap_or(Value::Null);
356    let mut endpoints = endpoints_from_tree(&tree);
357    endpoints.sort_by(|a, b| a.contract.cmp(&b.contract).then(a.qname.cmp(&b.qname)));
358    let edges = match_edges(&endpoints);
359    json!({ "endpoints": endpoints, "edges": edges })
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn package_of_uses_parent_dir() {
368        assert_eq!(package_of("crates/core/src/lib.rs"), "crates/core/src");
369        assert_eq!(package_of("main.rs"), ".");
370    }
371
372    #[test]
373    fn sorted_counts_orders_desc_then_name_and_truncates() {
374        let mut m = HashMap::new();
375        m.insert("a".to_string(), 1);
376        m.insert("b".to_string(), 3);
377        m.insert("c".to_string(), 3);
378        let v = sorted_counts(m, 2);
379        assert_eq!(v.len(), 2);
380        assert_eq!(v[0]["name"], "b");
381        assert_eq!(v[0]["symbols"], 3);
382        assert_eq!(v[1]["name"], "c");
383    }
384
385    #[test]
386    fn runtime_entries_excluded() {
387        assert!(is_runtime_entry("app.main"));
388        assert!(is_runtime_entry("Foo.__init__"));
389        assert!(is_runtime_entry("mod::__repr__"));
390        assert!(!is_runtime_entry("app.helper"));
391        assert!(!is_runtime_entry("Foo.compute"));
392        assert!(!is_runtime_entry("app.do_thing"));
393    }
394
395    #[test]
396    fn test_symbols_detected_by_path_and_qname() {
397        assert!(is_test_symbol(
398            "crates/x/src/lib.rs",
399            "x.lib.tests.it_works"
400        ));
401        assert!(is_test_symbol(
402            "crates/x/src/lib.rs",
403            "x::lib::tests::it_works"
404        ));
405        assert!(is_test_symbol("crates/x/tests/foo.rs", "x.foo.bar"));
406        assert!(!is_test_symbol("crates/x/src/lib.rs", "x.lib.compute"));
407        assert!(!is_test_symbol("src/lib.rs", "app.latest_value"));
408    }
409}