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 file_line = format!("{}:{}", seed.file.display(), seed.span.start_line);
96
97    let mut lines = vec![format!("`{}` ({}) — {}", seed.name, seed.kind, file_line)];
98
99    lines.push(format!("  • called by: {}", format_list(&callers)));
100    lines.push(format!("  • calls: {}", format_list(&callees)));
101
102    if !used_types.is_empty() {
103        lines.push(format!("  • uses types: {}", format_list(&used_types)));
104    }
105    if !implements.is_empty() {
106        lines.push(format!("  • implements: {}", format_list(&implements)));
107    }
108
109    lines.push(format!(
110        "  • subgraph: {} nodes, {} edges (depth {})",
111        nodes.len(),
112        edges.len(),
113        depth
114    ));
115
116    lines.join("\n")
117}
118
119fn format_list(names: &[&str]) -> String {
120    if names.is_empty() {
121        return "nothing".to_owned();
122    }
123    let shown: Vec<&str> = names.iter().copied().take(MAX_NAMES_IN_LIST).collect();
124    let extra = names.len().saturating_sub(MAX_NAMES_IN_LIST);
125    if extra == 0 {
126        shown.join(", ")
127    } else {
128        format!("{} (+{} more)", shown.join(", "), extra)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::path::PathBuf;
135
136    use gitcortex_core::{
137        graph::{Edge, NodeId, NodeMetadata, Span},
138        schema::{EdgeKind, NodeKind},
139    };
140
141    use super::*;
142    use gitcortex_core::graph::Node;
143
144    fn node(name: &str) -> Node {
145        Node {
146            id: NodeId::new(),
147            kind: NodeKind::Function,
148            name: name.to_owned(),
149            qualified_name: name.to_owned(),
150            file: PathBuf::from(format!("src/{name}.rs")),
151            span: Span {
152                start_line: 1,
153                end_line: 10,
154            },
155            metadata: NodeMetadata::default(),
156        }
157    }
158
159    // ── empty ────────────────────────────────────────────────────────────────
160
161    #[test]
162    fn empty_nodes_returns_not_found_hint() {
163        let s = build_prose_summary("Main", &[], &[], 1);
164        assert!(s.contains("No symbol matching 'Main'"), "got: {s}");
165        assert!(s.contains("search_code"), "should suggest search_code: {s}");
166    }
167
168    // ── seed only ────────────────────────────────────────────────────────────
169
170    #[test]
171    fn seed_only_no_connections() {
172        let n = node("main");
173        let s = build_prose_summary("main", &[n], &[], 1);
174        assert!(s.contains("`main`"), "got: {s}");
175        assert!(s.contains("called by: nothing"), "got: {s}");
176        assert!(s.contains("calls: nothing"), "got: {s}");
177        assert!(s.contains("1 nodes, 0 edges"), "got: {s}");
178    }
179
180    // ── callers and callees ──────────────────────────────────────────────────
181
182    #[test]
183    fn callers_and_callees_appear_in_prose() {
184        let seed = node("main");
185        let caller = node("bootstrap");
186        let callee1 = node("run");
187        let callee2 = node("parse_args");
188
189        let seed_id = seed.id.clone();
190        let caller_id = caller.id.clone();
191        let callee1_id = callee1.id.clone();
192        let callee2_id = callee2.id.clone();
193
194        let nodes = vec![seed, caller, callee1, callee2];
195        let edges = vec![
196            Edge::new(caller_id, seed_id.clone(), EdgeKind::Calls),
197            Edge::new(seed_id.clone(), callee1_id, EdgeKind::Calls),
198            Edge::new(seed_id, callee2_id, EdgeKind::Calls),
199        ];
200
201        let s = build_prose_summary("main", &nodes, &edges, 1);
202        assert!(s.contains("bootstrap"), "caller missing: {s}");
203        assert!(s.contains("run"), "callee1 missing: {s}");
204        assert!(s.contains("parse_args"), "callee2 missing: {s}");
205    }
206
207    // ── uses / implements ────────────────────────────────────────────────────
208
209    #[test]
210    fn uses_and_implements_edges_appear() {
211        let seed = node("Router");
212        let iface = node("Handler");
213        let typ = node("Request");
214
215        let seed_id = seed.id.clone();
216        let iface_id = iface.id.clone();
217        let typ_id = typ.id.clone();
218
219        let nodes = vec![seed, iface, typ];
220        let edges = vec![
221            Edge::new(seed_id.clone(), iface_id, EdgeKind::Implements),
222            Edge::new(seed_id, typ_id, EdgeKind::Uses),
223        ];
224
225        let s = build_prose_summary("Router", &nodes, &edges, 1);
226        assert!(s.contains("Handler"), "implements missing: {s}");
227        assert!(s.contains("Request"), "uses missing: {s}");
228        assert!(s.contains("uses types"), "uses types label missing: {s}");
229        assert!(s.contains("implements"), "implements label missing: {s}");
230    }
231
232    // ── cap at MAX_NAMES_IN_LIST ─────────────────────────────────────────────
233
234    #[test]
235    fn long_callee_list_is_truncated_with_plus_more() {
236        let seed = node("hub");
237        let seed_id = seed.id.clone();
238        let callees: Vec<Node> = (0..8).map(|i| node(&format!("callee{i}"))).collect();
239        let mut nodes = vec![seed];
240        let mut edges = Vec::new();
241        for c in &callees {
242            edges.push(Edge::new(seed_id.clone(), c.id.clone(), EdgeKind::Calls));
243        }
244        nodes.extend(callees);
245
246        let s = build_prose_summary("hub", &nodes, &edges, 1);
247        assert!(s.contains("+3 more"), "expected +3 more for 8 callees: {s}");
248    }
249
250    // ── case-insensitive seed matching ───────────────────────────────────────
251
252    #[test]
253    fn seed_found_case_insensitively() {
254        let n = node("main"); // stored as lowercase
255        let s = build_prose_summary("Main", &[n], &[], 1); // queried with capital M
256        assert!(
257            s.contains("`main`"),
258            "should find seed case-insensitively: {s}"
259        );
260        assert!(!s.contains("No symbol"), "should not return not-found: {s}");
261    }
262}