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 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 #[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 #[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 #[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 #[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 #[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 #[test]
253 fn seed_found_case_insensitively() {
254 let n = node("main"); let s = build_prose_summary("Main", &[n], &[], 1); 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}