1use gitcortex_core::{
18 graph::{Edge, Node},
19 schema::EdgeKind,
20};
21
22const MAX_NAMES_IN_LIST: usize = 5;
23
24pub 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 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 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 let mut callers: Vec<&str> = Vec::new(); let mut callees: Vec<&str> = Vec::new(); let mut used_types: Vec<&str> = Vec::new(); let mut implements: Vec<&str> = Vec::new(); 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 #[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 #[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 #[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 #[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 #[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 #[test]
273 fn seed_found_case_insensitively() {
274 let n = node("main"); let s = build_prose_summary("Main", &[n], &[], 1); 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}