Skip to main content

gitcortex_mcp/mcp/
subgraph.rs

1//! Prose-summary generation for `get_subgraph` responses.
2//!
3//! The raw subgraph (nodes + edges as JSON arrays) forces a model to iterate
4//! many times to extract relationship facts. A single prose `summary` string
5//! delivers the same information in one read, cutting turns from 20+ to 1.
6//!
7//! The summary is intentionally compact and model-readable:
8//!
9//! ```text
10//! `main` (Function) — src/main.rs:1
11//!   • called by: nothing
12//!   • calls: run, setup_tracing, parse_args, handle_signals
13//!   • uses types: Config, Args
14//!   • subgraph: 5 nodes, 4 edges (depth 1)
15//! ```
16
17use gitcortex_core::{
18    graph::{Edge, Node},
19    schema::EdgeKind,
20};
21
22const MAX_NAMES_IN_LIST: usize = 5;
23
24/// Build a prose summary of the subgraph centred on `seed_name`.
25///
26/// Returns an empty-result hint when the subgraph has no nodes so the model
27/// can suggest a follow-up action rather than silently stopping.
28pub fn build_prose_summary(seed_name: &str, nodes: &[Node], edges: &[Edge], depth: u8) -> String {
29    if nodes.is_empty() {
30        return format!(
31            "No symbol matching '{seed_name}' found in this branch's graph. \
32             Try `search_code` to locate the nearest match by name."
33        );
34    }
35
36    // Find the seed node (case-insensitive so "Main" matches "main").
37    // nodes.is_empty() is guarded above, so nodes[0] is safe.
38    let seed = nodes
39        .iter()
40        .find(|n| n.name.eq_ignore_ascii_case(seed_name))
41        .unwrap_or(&nodes[0]);
42
43    let seed_id = seed.id.as_str();
44
45    // Build id→name map for edge label resolution.
46    let id_to_name: std::collections::HashMap<String, &str> = nodes
47        .iter()
48        .map(|n| (n.id.as_str(), n.name.as_str()))
49        .collect();
50
51    // Classify edges by kind relative to the seed.
52    let mut callers: Vec<&str> = Vec::new(); // edges that point INTO seed (Calls)
53    let mut callees: Vec<&str> = Vec::new(); // edges that seed points OUT (Calls)
54    let mut used_types: Vec<&str> = Vec::new(); // Uses edges from seed
55    let mut implements: Vec<&str> = Vec::new(); // Implements edges from seed
56
57    for e in edges {
58        let src = e.src.as_str();
59        let dst = e.dst.as_str();
60        match e.kind {
61            EdgeKind::Calls => {
62                if src == seed_id {
63                    if let Some(name) = id_to_name.get(&dst) {
64                        callees.push(name);
65                    }
66                } else if dst == seed_id {
67                    if let Some(name) = id_to_name.get(&src) {
68                        callers.push(name);
69                    }
70                }
71            }
72            EdgeKind::Uses if src == seed_id => {
73                if let Some(name) = id_to_name.get(&dst) {
74                    used_types.push(name);
75                }
76            }
77            EdgeKind::Implements if src == seed_id => {
78                if let Some(name) = id_to_name.get(&dst) {
79                    implements.push(name);
80                }
81            }
82            _ => {}
83        }
84    }
85
86    callers.sort();
87    callers.dedup();
88    callees.sort();
89    callees.dedup();
90    used_types.sort();
91    used_types.dedup();
92    implements.sort();
93    implements.dedup();
94
95    let calls_edges: Vec<&Edge> = edges
96        .iter()
97        .filter(|e| matches!(e.kind, EdgeKind::Calls))
98        .collect();
99    let direct = calls_edges
100        .iter()
101        .filter(|e| {
102            matches!(
103                e.confidence,
104                gitcortex_core::schema::EdgeConfidence::Extracted
105            )
106        })
107        .count();
108    let inferred = calls_edges.len() - direct;
109    let conf_note = if calls_edges.is_empty() {
110        String::new()
111    } else {
112        format!(", {direct} direct + {inferred} inferred calls")
113    };
114
115    let file_line = format!("{}:{}", seed.file.display(), seed.span.start_line);
116
117    let mut lines = vec![format!("`{}` ({}) — {}", seed.name, seed.kind, file_line)];
118
119    lines.push(format!("  • called by: {}", format_list(&callers)));
120    lines.push(format!("  • calls: {}", format_list(&callees)));
121
122    if !used_types.is_empty() {
123        lines.push(format!("  • uses types: {}", format_list(&used_types)));
124    }
125    if !implements.is_empty() {
126        lines.push(format!("  • implements: {}", format_list(&implements)));
127    }
128
129    lines.push(format!(
130        "  • subgraph: {} nodes, {} edges (depth {depth}{})",
131        nodes.len(),
132        edges.len(),
133        conf_note
134    ));
135
136    lines.join("\n")
137}
138
139fn format_list(names: &[&str]) -> String {
140    if names.is_empty() {
141        return "nothing".to_owned();
142    }
143    let shown: Vec<&str> = names.iter().copied().take(MAX_NAMES_IN_LIST).collect();
144    let extra = names.len().saturating_sub(MAX_NAMES_IN_LIST);
145    if extra == 0 {
146        shown.join(", ")
147    } else {
148        format!("{} (+{} more)", shown.join(", "), extra)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use std::path::PathBuf;
155
156    use gitcortex_core::{
157        graph::{Edge, NodeId, NodeMetadata, Span},
158        schema::{EdgeKind, NodeKind},
159    };
160
161    use super::*;
162    use gitcortex_core::graph::Node;
163
164    fn node(name: &str) -> Node {
165        Node {
166            id: NodeId::new(),
167            kind: NodeKind::Function,
168            name: name.to_owned(),
169            qualified_name: name.to_owned(),
170            file: PathBuf::from(format!("src/{name}.rs")),
171            span: Span {
172                start_line: 1,
173                end_line: 10,
174            },
175            metadata: NodeMetadata::default(),
176        }
177    }
178
179    // ── empty ────────────────────────────────────────────────────────────────
180
181    #[test]
182    fn empty_nodes_returns_not_found_hint() {
183        let s = build_prose_summary("Main", &[], &[], 1);
184        assert!(s.contains("No symbol matching 'Main'"), "got: {s}");
185        assert!(s.contains("search_code"), "should suggest search_code: {s}");
186    }
187
188    // ── seed only ────────────────────────────────────────────────────────────
189
190    #[test]
191    fn seed_only_no_connections() {
192        let n = node("main");
193        let s = build_prose_summary("main", &[n], &[], 1);
194        assert!(s.contains("`main`"), "got: {s}");
195        assert!(s.contains("called by: nothing"), "got: {s}");
196        assert!(s.contains("calls: nothing"), "got: {s}");
197        assert!(s.contains("1 nodes, 0 edges"), "got: {s}");
198    }
199
200    // ── callers and callees ──────────────────────────────────────────────────
201
202    #[test]
203    fn callers_and_callees_appear_in_prose() {
204        let seed = node("main");
205        let caller = node("bootstrap");
206        let callee1 = node("run");
207        let callee2 = node("parse_args");
208
209        let seed_id = seed.id.clone();
210        let caller_id = caller.id.clone();
211        let callee1_id = callee1.id.clone();
212        let callee2_id = callee2.id.clone();
213
214        let nodes = vec![seed, caller, callee1, callee2];
215        let edges = vec![
216            Edge::new(caller_id, seed_id.clone(), EdgeKind::Calls),
217            Edge::new(seed_id.clone(), callee1_id, EdgeKind::Calls),
218            Edge::new(seed_id, callee2_id, EdgeKind::Calls),
219        ];
220
221        let s = build_prose_summary("main", &nodes, &edges, 1);
222        assert!(s.contains("bootstrap"), "caller missing: {s}");
223        assert!(s.contains("run"), "callee1 missing: {s}");
224        assert!(s.contains("parse_args"), "callee2 missing: {s}");
225    }
226
227    // ── uses / implements ────────────────────────────────────────────────────
228
229    #[test]
230    fn uses_and_implements_edges_appear() {
231        let seed = node("Router");
232        let iface = node("Handler");
233        let typ = node("Request");
234
235        let seed_id = seed.id.clone();
236        let iface_id = iface.id.clone();
237        let typ_id = typ.id.clone();
238
239        let nodes = vec![seed, iface, typ];
240        let edges = vec![
241            Edge::new(seed_id.clone(), iface_id, EdgeKind::Implements),
242            Edge::new(seed_id, typ_id, EdgeKind::Uses),
243        ];
244
245        let s = build_prose_summary("Router", &nodes, &edges, 1);
246        assert!(s.contains("Handler"), "implements missing: {s}");
247        assert!(s.contains("Request"), "uses missing: {s}");
248        assert!(s.contains("uses types"), "uses types label missing: {s}");
249        assert!(s.contains("implements"), "implements label missing: {s}");
250    }
251
252    // ── cap at MAX_NAMES_IN_LIST ─────────────────────────────────────────────
253
254    #[test]
255    fn long_callee_list_is_truncated_with_plus_more() {
256        let seed = node("hub");
257        let seed_id = seed.id.clone();
258        let callees: Vec<Node> = (0..8).map(|i| node(&format!("callee{i}"))).collect();
259        let mut nodes = vec![seed];
260        let mut edges = Vec::new();
261        for c in &callees {
262            edges.push(Edge::new(seed_id.clone(), c.id.clone(), EdgeKind::Calls));
263        }
264        nodes.extend(callees);
265
266        let s = build_prose_summary("hub", &nodes, &edges, 1);
267        assert!(s.contains("+3 more"), "expected +3 more for 8 callees: {s}");
268    }
269
270    // ── case-insensitive seed matching ───────────────────────────────────────
271
272    #[test]
273    fn seed_found_case_insensitively() {
274        let n = node("main"); // stored as lowercase
275        let s = build_prose_summary("Main", &[n], &[], 1); // queried with capital M
276        assert!(
277            s.contains("`main`"),
278            "should find seed case-insensitively: {s}"
279        );
280        assert!(!s.contains("No symbol"), "should not return not-found: {s}");
281    }
282}