1use std::collections::{HashMap, HashSet, VecDeque};
12
13use gitcortex_core::{
14 error::Result,
15 graph::Node,
16 schema::{EdgeKind, NodeKind},
17 store::GraphStore,
18};
19use serde::Serialize;
20
21use super::centrality::in_degree_by_calls;
22
23#[derive(Debug, Clone, Serialize)]
25pub struct TourStep {
26 pub order: u32,
27 pub name: String,
28 pub qualified_name: String,
29 pub kind: String,
30 pub file: String,
31 pub start_line: u32,
32 pub reason: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct Component {
40 pub path: String,
42 pub files: u32,
44 pub key_symbols: Vec<String>,
46 pub depends_on: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct Tour {
52 pub seed: Option<String>,
53 pub branch: String,
54 pub steps: Vec<TourStep>,
55 pub components: Vec<Component>,
58}
59
60const DEFAULT_TOUR_LEN: usize = 12;
62const MAX_TOUR_LEN: usize = 50;
64const NO_SEED_STEP_CAP: usize = 5;
67
68pub fn generate<S: GraphStore + ?Sized>(
72 store: &S,
73 branch: &str,
74 seed: Option<&str>,
75 limit: Option<usize>,
76) -> Result<Tour> {
77 let limit = limit.unwrap_or(DEFAULT_TOUR_LEN).min(MAX_TOUR_LEN);
78 let nodes = store.list_all_nodes(branch)?;
79 let edges = store.list_all_edges(branch)?;
80
81 let in_degree = in_degree_by_calls(&edges);
82 let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
83 for e in &edges {
84 if matches!(e.kind, EdgeKind::Calls) {
85 callees_of
86 .entry(e.src.as_str())
87 .or_default()
88 .push(e.dst.as_str());
89 }
90 }
91
92 let mut dep_edges: Vec<(String, String)> = Vec::new();
95 for e in &edges {
96 if matches!(e.kind, EdgeKind::Calls | EdgeKind::Uses | EdgeKind::Imports) {
97 dep_edges.push((e.src.as_str(), e.dst.as_str()));
98 }
99 }
100
101 let by_id: HashMap<String, Node> = nodes.into_iter().map(|n| (n.id.as_str(), n)).collect();
102
103 let (steps, components) = match seed {
104 Some(name) => (
105 seeded_tour(&by_id, &callees_of, &in_degree, name, limit),
106 Vec::new(),
107 ),
108 None => (
109 global_tour(&by_id, &in_degree, limit.min(NO_SEED_STEP_CAP)),
113 architecture_summary(&by_id, &in_degree, &dep_edges, limit),
114 ),
115 };
116
117 Ok(Tour {
118 seed: seed.map(str::to_owned),
119 branch: branch.to_owned(),
120 steps,
121 components,
122 })
123}
124
125fn component_of(file: &str) -> String {
128 match file.rfind('/') {
129 Some(i) => file[..i].to_owned(),
130 None => file.to_owned(),
131 }
132}
133
134fn architecture_summary(
138 by_id: &HashMap<String, Node>,
139 in_degree: &HashMap<String, u32>,
140 dep_edges: &[(String, String)],
141 limit: usize,
142) -> Vec<Component> {
143 let comp_of_id: HashMap<&str, String> = by_id
145 .iter()
146 .map(|(id, n)| (id.as_str(), component_of(&n.file.display().to_string())))
147 .collect();
148
149 let mut files: HashMap<String, HashSet<String>> = HashMap::new();
151 let mut score: HashMap<String, u32> = HashMap::new();
152 let mut symbols: HashMap<String, Vec<(String, u32)>> = HashMap::new();
155 for n in by_id.values() {
156 let file = n.file.display().to_string();
157 let comp = component_of(&file);
158 files.entry(comp.clone()).or_default().insert(file.clone());
159 let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
160 *score.entry(comp.clone()).or_insert(0) += deg;
161 if matches!(
162 n.kind,
163 NodeKind::Function
164 | NodeKind::Method
165 | NodeKind::Struct
166 | NodeKind::Trait
167 | NodeKind::Interface
168 ) {
169 let label = format!("{} — {}:{}", n.name, file, n.span.start_line);
170 symbols.entry(comp).or_default().push((label, deg));
171 }
172 }
173
174 let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
176 for (src, dst) in dep_edges {
177 if let (Some(sc), Some(dc)) = (comp_of_id.get(src.as_str()), comp_of_id.get(dst.as_str())) {
178 if sc != dc {
179 deps.entry(sc.clone()).or_default().insert(dc.clone());
180 }
181 }
182 }
183
184 let mut ranked: Vec<(String, u32)> = score.into_iter().collect();
185 ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
186
187 ranked
188 .into_iter()
189 .take(limit)
190 .map(|(comp, _)| {
191 let mut key = symbols.remove(&comp).unwrap_or_default();
192 key.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
193 key.dedup_by(|a, b| a.0 == b.0);
194 let key_symbols: Vec<String> = key.into_iter().take(4).map(|(name, _)| name).collect();
195 let mut depends_on: Vec<String> = deps
196 .get(&comp)
197 .map(|s| s.iter().cloned().collect())
198 .unwrap_or_default();
199 depends_on.sort();
200 Component {
201 files: files.get(&comp).map(|f| f.len() as u32).unwrap_or(0),
202 path: comp,
203 key_symbols,
204 depends_on,
205 }
206 })
207 .collect()
208}
209
210fn global_tour(
212 by_id: &HashMap<String, Node>,
213 in_degree: &HashMap<String, u32>,
214 limit: usize,
215) -> Vec<TourStep> {
216 let mut scored: Vec<(&Node, u32)> = by_id
217 .values()
218 .filter(|n| {
219 matches!(
220 n.kind,
221 NodeKind::Function
222 | NodeKind::Method
223 | NodeKind::Struct
224 | NodeKind::Trait
225 | NodeKind::Interface
226 )
227 })
228 .map(|n| {
229 let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
230 (n, deg)
231 })
232 .collect();
233 scored.sort_by(|a, b| {
235 b.1.cmp(&a.1)
236 .then_with(|| a.0.qualified_name.cmp(&b.0.qualified_name))
237 });
238
239 scored
240 .into_iter()
241 .take(limit)
242 .enumerate()
243 .map(|(i, (n, deg))| TourStep {
244 order: (i + 1) as u32,
245 name: n.name.clone(),
246 qualified_name: n.qualified_name.clone(),
247 kind: n.kind.to_string(),
248 file: n.file.display().to_string(),
249 start_line: n.span.start_line,
250 reason: if deg == 0 {
251 "public surface (no inbound calls)".into()
252 } else {
253 format!("central — {deg} inbound calls")
254 },
255 })
256 .collect()
257}
258
259fn seeded_tour(
261 by_id: &HashMap<String, Node>,
262 callees_of: &HashMap<String, Vec<String>>,
263 in_degree: &HashMap<String, u32>,
264 seed_name: &str,
265 limit: usize,
266) -> Vec<TourStep> {
267 let seed_node = by_id
271 .values()
272 .filter(|n| n.name == seed_name)
273 .max_by_key(|n| in_degree.get(&n.id.as_str()).copied().unwrap_or(0));
274 let Some(seed) = seed_node else {
275 return Vec::new();
276 };
277
278 let mut visited: HashSet<String> = HashSet::new();
279 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
280 queue.push_back((seed.id.as_str(), 0));
281 visited.insert(seed.id.as_str());
282
283 let mut steps: Vec<TourStep> = Vec::new();
284 while let Some((id, hop)) = queue.pop_front() {
285 if steps.len() >= limit {
286 break;
287 }
288 let Some(n) = by_id.get(&id) else { continue };
289 let reason = if hop == 0 {
290 "seed".into()
291 } else if hop == 1 {
292 "directly called by seed".into()
293 } else {
294 format!("{hop} hops from seed")
295 };
296 steps.push(TourStep {
297 order: (steps.len() + 1) as u32,
298 name: n.name.clone(),
299 qualified_name: n.qualified_name.clone(),
300 kind: n.kind.to_string(),
301 file: n.file.display().to_string(),
302 start_line: n.span.start_line,
303 reason,
304 });
305 if let Some(next) = callees_of.get(&id) {
306 for callee_id in next {
307 if visited.insert(callee_id.clone()) {
308 queue.push_back((callee_id.clone(), hop + 1));
309 }
310 }
311 }
312 }
313
314 steps
315}
316
317pub fn render_markdown(tour: &Tour) -> String {
319 use std::fmt::Write;
320 let mut out = String::with_capacity(512);
321
322 if tour.seed.is_none() && !tour.components.is_empty() {
327 let _ = writeln!(out, "# Architecture (branch={})", tour.branch);
328 let _ = writeln!(
329 out,
330 "\n## Components ({} shown, ranked by centrality)\n",
331 tour.components.len()
332 );
333 for c in &tour.components {
334 let _ = writeln!(out, "### `{}` ({} files)", c.path, c.files);
335 if !c.key_symbols.is_empty() {
336 let keys = c
337 .key_symbols
338 .iter()
339 .map(|s| format!("`{s}`"))
340 .collect::<Vec<_>>()
341 .join(", ");
342 let _ = writeln!(out, "- key: {keys}");
343 }
344 if !c.depends_on.is_empty() {
345 let _ = writeln!(out, "- depends on: {}", c.depends_on.join(", "));
346 }
347 }
348 if !tour.steps.is_empty() {
349 let names = tour
350 .steps
351 .iter()
352 .map(|s| format!("`{}`", s.name))
353 .collect::<Vec<_>>()
354 .join(", ");
355 let _ = writeln!(out, "\n## Most central overall\n{names}");
356 }
357 return out;
358 }
359
360 let _ = writeln!(
361 out,
362 "# Tour ({} steps, branch={})",
363 tour.steps.len(),
364 tour.branch
365 );
366 if let Some(seed) = &tour.seed {
367 let _ = writeln!(out, "Seed: `{seed}`");
368 }
369 let _ = writeln!(out);
370 for s in &tour.steps {
371 let _ = writeln!(
372 out,
373 "{}. `{}` ({}) — `{}:{}` _{}_",
374 s.order, s.name, s.kind, s.file, s.start_line, s.reason
375 );
376 }
377 out
378}