1use std::collections::{HashMap, HashSet, VecDeque};
12
13use gitcortex_core::{
14 error::Result,
15 graph::Node,
16 schema::{EdgeConfidence, EdgeKind, NodeKind, Visibility},
17 store::GraphStore,
18};
19use serde::Serialize;
20
21use super::{centrality::in_degree_by_calls, helpers::is_test_file};
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 #[serde(skip_serializing_if = "Option::is_none")]
37 pub community: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct Component {
43 pub path: String,
45 pub files: u32,
47 pub key_symbols: Vec<String>,
49 pub depends_on: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct Tour {
55 pub seed: Option<String>,
56 pub branch: String,
57 pub steps: Vec<TourStep>,
58 pub components: Vec<Component>,
61}
62
63const DEFAULT_TOUR_LEN: usize = 6;
65const MAX_TOUR_LEN: usize = 20;
67const NO_SEED_STEP_CAP: usize = 8;
69
70pub fn generate<S: GraphStore + ?Sized>(
74 store: &S,
75 branch: &str,
76 seed: Option<&str>,
77 limit: Option<usize>,
78) -> Result<Tour> {
79 let limit = limit.unwrap_or(DEFAULT_TOUR_LEN).min(MAX_TOUR_LEN);
80 let nodes = store.list_all_nodes(branch)?;
81 let edges = store.list_all_edges(branch)?;
82
83 let in_degree = in_degree_by_calls(&edges);
84 let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
85 for e in &edges {
86 if matches!(e.kind, EdgeKind::Calls) {
87 callees_of
88 .entry(e.src.as_str())
89 .or_default()
90 .push(e.dst.as_str());
91 }
92 }
93
94 let mut dep_edges: Vec<(String, String)> = Vec::new();
97 for e in &edges {
98 if matches!(e.kind, EdgeKind::Calls | EdgeKind::Uses | EdgeKind::Imports)
99 && !matches!(e.confidence, EdgeConfidence::Inferred)
100 {
101 dep_edges.push((e.src.as_str(), e.dst.as_str()));
102 }
103 }
104
105 let by_id: HashMap<String, Node> = nodes.into_iter().map(|n| (n.id.as_str(), n)).collect();
106
107 let (steps, components) = match seed {
108 Some(name) => (
109 seeded_tour(&by_id, &callees_of, &in_degree, name, limit),
110 Vec::new(),
111 ),
112 None => (
113 global_tour(&by_id, &in_degree, limit.min(NO_SEED_STEP_CAP)),
114 architecture_summary(&by_id, &in_degree, &dep_edges, limit),
115 ),
116 };
117
118 Ok(Tour {
119 seed: seed.map(str::to_owned),
120 branch: branch.to_owned(),
121 steps,
122 components,
123 })
124}
125
126fn component_of(file: &str) -> String {
129 match file.rfind('/') {
130 Some(i) => file[..i].to_owned(),
131 None => "<root>".to_owned(),
132 }
133}
134
135fn is_agent_relevant(node: &Node) -> bool {
136 let path = node.file.to_string_lossy();
137 let lower = path.to_ascii_lowercase().replace('\\', "/");
138 let generated_or_docs = lower.starts_with("docs/")
139 || lower.starts_with("site/")
140 || lower.starts_with("examples/")
141 || lower.contains("/generated/")
142 || lower.contains("/vendor/")
143 || lower.contains("/node_modules/")
144 || lower.contains("/target/");
145 !generated_or_docs
146 && !is_test_file(&node.file)
147 && !matches!(node.metadata.visibility, Visibility::Private)
148 && matches!(
149 node.kind,
150 NodeKind::Function
151 | NodeKind::Method
152 | NodeKind::Struct
153 | NodeKind::Trait
154 | NodeKind::Interface
155 | NodeKind::Enum
156 )
157}
158
159fn tour_score(node: &Node, in_degree: u32) -> u32 {
160 let kind_weight = match node.kind {
161 NodeKind::Struct | NodeKind::Trait | NodeKind::Interface | NodeKind::Enum => 100,
162 NodeKind::Function => 70,
163 NodeKind::Method => 10,
164 _ => 0,
165 };
166 kind_weight + in_degree.min(30)
170}
171
172fn architecture_summary(
176 by_id: &HashMap<String, Node>,
177 in_degree: &HashMap<String, u32>,
178 dep_edges: &[(String, String)],
179 limit: usize,
180) -> Vec<Component> {
181 let comp_of_id: HashMap<&str, String> = by_id
183 .iter()
184 .filter(|(_, node)| is_agent_relevant(node))
185 .map(|(id, node)| (id.as_str(), component_of(&node.file.display().to_string())))
186 .collect();
187
188 let mut files: HashMap<String, HashSet<String>> = HashMap::new();
190 let mut score: HashMap<String, u32> = HashMap::new();
191 let mut symbols: HashMap<String, Vec<(String, u32)>> = HashMap::new();
194 for n in by_id.values().filter(|node| is_agent_relevant(node)) {
195 let file = n.file.display().to_string();
196 let comp = component_of(&file);
197 files.entry(comp.clone()).or_default().insert(file.clone());
198 let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
199 *score.entry(comp.clone()).or_insert(0) += tour_score(n, deg);
200 if matches!(
201 n.kind,
202 NodeKind::Function
203 | NodeKind::Method
204 | NodeKind::Struct
205 | NodeKind::Trait
206 | NodeKind::Interface
207 | NodeKind::Enum
208 ) {
209 let label = format!("{} — {}:{}", n.name, file, n.span.start_line);
210 symbols
211 .entry(comp)
212 .or_default()
213 .push((label, tour_score(n, deg)));
214 }
215 }
216
217 let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
219 for (src, dst) in dep_edges {
220 if let (Some(sc), Some(dc)) = (comp_of_id.get(src.as_str()), comp_of_id.get(dst.as_str())) {
221 if sc != dc {
222 deps.entry(sc.clone()).or_default().insert(dc.clone());
223 }
224 }
225 }
226
227 let mut ranked: Vec<(String, u32)> = score.into_iter().collect();
228 ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
229
230 ranked
231 .into_iter()
232 .take(limit)
233 .map(|(comp, _)| {
234 let mut key = symbols.remove(&comp).unwrap_or_default();
235 key.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
236 key.dedup_by(|a, b| a.0 == b.0);
237 let key_symbols: Vec<String> = key.into_iter().take(2).map(|(name, _)| name).collect();
238 let mut depends_on: Vec<String> = deps
239 .get(&comp)
240 .map(|s| s.iter().cloned().collect())
241 .unwrap_or_default();
242 depends_on.sort();
243 depends_on.truncate(5);
244 Component {
245 files: files.get(&comp).map(|f| f.len() as u32).unwrap_or(0),
246 path: comp,
247 key_symbols,
248 depends_on,
249 }
250 })
251 .collect()
252}
253
254fn global_tour(
256 by_id: &HashMap<String, Node>,
257 in_degree: &HashMap<String, u32>,
258 limit: usize,
259) -> Vec<TourStep> {
260 let mut scored: Vec<(&Node, u32, u32)> = by_id
261 .values()
262 .filter(|node| is_agent_relevant(node) && !matches!(node.kind, NodeKind::Method))
263 .map(|node| {
264 let degree = in_degree.get(&node.id.as_str()).copied().unwrap_or(0);
265 (node, tour_score(node, degree), degree)
266 })
267 .collect();
268 scored.sort_by(|a, b| {
270 b.1.cmp(&a.1)
271 .then_with(|| a.0.qualified_name.cmp(&b.0.qualified_name))
272 });
273
274 scored
275 .into_iter()
276 .take(limit)
277 .enumerate()
278 .map(|(i, (n, _, deg))| TourStep {
279 order: (i + 1) as u32,
280 name: n.name.clone(),
281 qualified_name: n.qualified_name.clone(),
282 kind: n.kind.to_string(),
283 file: n.file.display().to_string(),
284 start_line: n.span.start_line,
285 reason: if deg == 0 {
286 "public surface (no inbound calls)".into()
287 } else {
288 format!("central — {deg} inbound calls")
289 },
290 community: None,
291 })
292 .collect()
293}
294
295fn seeded_tour(
297 by_id: &HashMap<String, Node>,
298 callees_of: &HashMap<String, Vec<String>>,
299 in_degree: &HashMap<String, u32>,
300 seed_name: &str,
301 limit: usize,
302) -> Vec<TourStep> {
303 let seed_node = by_id
307 .values()
308 .filter(|n| n.name == seed_name)
309 .max_by_key(|n| in_degree.get(&n.id.as_str()).copied().unwrap_or(0));
310 let Some(seed) = seed_node else {
311 return Vec::new();
312 };
313
314 let mut visited: HashSet<String> = HashSet::new();
315 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
316 queue.push_back((seed.id.as_str(), 0));
317 visited.insert(seed.id.as_str());
318
319 let mut steps: Vec<TourStep> = Vec::new();
320 while let Some((id, hop)) = queue.pop_front() {
321 if steps.len() >= limit {
322 break;
323 }
324 let Some(n) = by_id.get(&id) else { continue };
325 let reason = if hop == 0 {
326 "seed".into()
327 } else if hop == 1 {
328 "directly called by seed".into()
329 } else {
330 format!("{hop} hops from seed")
331 };
332 steps.push(TourStep {
333 order: (steps.len() + 1) as u32,
334 name: n.name.clone(),
335 qualified_name: n.qualified_name.clone(),
336 kind: n.kind.to_string(),
337 file: n.file.display().to_string(),
338 start_line: n.span.start_line,
339 reason,
340 community: None,
341 });
342 if let Some(next) = callees_of.get(&id) {
343 for callee_id in next {
344 if visited.insert(callee_id.clone()) {
345 queue.push_back((callee_id.clone(), hop + 1));
346 }
347 }
348 }
349 }
350
351 steps
352}
353
354pub fn render_markdown(tour: &Tour) -> String {
356 use std::fmt::Write;
357 let mut out = String::with_capacity(512);
358
359 if tour.seed.is_none() && !tour.components.is_empty() {
364 let _ = writeln!(out, "# Architecture (branch={})", tour.branch);
365
366 let _ = writeln!(
367 out,
368 "\n## Components ({} shown, ranked by centrality)\n",
369 tour.components.len()
370 );
371 for c in &tour.components {
372 let _ = writeln!(out, "### `{}` ({} files)", c.path, c.files);
373 if !c.key_symbols.is_empty() {
374 let keys = c
375 .key_symbols
376 .iter()
377 .map(|s| format!("`{s}`"))
378 .collect::<Vec<_>>()
379 .join(", ");
380 let _ = writeln!(out, "- key: {keys}");
381 }
382 if !c.depends_on.is_empty() {
383 let _ = writeln!(out, "- depends on: {}", c.depends_on.join(", "));
384 }
385 }
386 return out;
387 }
388
389 let _ = writeln!(
390 out,
391 "# Tour ({} steps, branch={})",
392 tour.steps.len(),
393 tour.branch
394 );
395 if let Some(seed) = &tour.seed {
396 let _ = writeln!(out, "Seed: `{seed}`");
397 }
398 let _ = writeln!(out);
399 for s in &tour.steps {
400 let _ = writeln!(
401 out,
402 "{}. `{}` ({}) — `{}:{}` _{}_",
403 s.order, s.name, s.kind, s.file, s.start_line, s.reason
404 );
405 }
406 out
407}