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
21#[derive(Debug, Clone, Serialize)]
23pub struct TourStep {
24 pub order: u32,
25 pub name: String,
26 pub qualified_name: String,
27 pub kind: String,
28 pub file: String,
29 pub start_line: u32,
30 pub reason: String,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct Tour {
37 pub seed: Option<String>,
38 pub branch: String,
39 pub steps: Vec<TourStep>,
40}
41
42const DEFAULT_TOUR_LEN: usize = 12;
44const MAX_TOUR_LEN: usize = 50;
46
47pub fn generate<S: GraphStore + ?Sized>(
51 store: &S,
52 branch: &str,
53 seed: Option<&str>,
54 limit: Option<usize>,
55) -> Result<Tour> {
56 let limit = limit.unwrap_or(DEFAULT_TOUR_LEN).min(MAX_TOUR_LEN);
57 let nodes = store.list_all_nodes(branch)?;
58 let edges = store.list_all_edges(branch)?;
59
60 let mut in_degree: HashMap<String, u32> = HashMap::new();
61 let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
62 for e in &edges {
63 if matches!(e.kind, EdgeKind::Calls) {
64 *in_degree.entry(e.dst.as_str()).or_insert(0) += 1;
65 callees_of
66 .entry(e.src.as_str())
67 .or_default()
68 .push(e.dst.as_str());
69 }
70 }
71
72 let by_id: HashMap<String, Node> = nodes.into_iter().map(|n| (n.id.as_str(), n)).collect();
73
74 let steps = match seed {
75 Some(name) => seeded_tour(&by_id, &callees_of, &in_degree, name, limit),
76 None => global_tour(&by_id, &in_degree, limit),
77 };
78
79 Ok(Tour {
80 seed: seed.map(str::to_owned),
81 branch: branch.to_owned(),
82 steps,
83 })
84}
85
86fn global_tour(
88 by_id: &HashMap<String, Node>,
89 in_degree: &HashMap<String, u32>,
90 limit: usize,
91) -> Vec<TourStep> {
92 let mut scored: Vec<(&Node, u32)> = by_id
93 .values()
94 .filter(|n| {
95 matches!(
96 n.kind,
97 NodeKind::Function
98 | NodeKind::Method
99 | NodeKind::Struct
100 | NodeKind::Trait
101 | NodeKind::Interface
102 )
103 })
104 .map(|n| {
105 let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
106 (n, deg)
107 })
108 .collect();
109 scored.sort_by(|a, b| {
111 b.1.cmp(&a.1)
112 .then_with(|| a.0.qualified_name.cmp(&b.0.qualified_name))
113 });
114
115 scored
116 .into_iter()
117 .take(limit)
118 .enumerate()
119 .map(|(i, (n, deg))| TourStep {
120 order: (i + 1) as u32,
121 name: n.name.clone(),
122 qualified_name: n.qualified_name.clone(),
123 kind: n.kind.to_string(),
124 file: n.file.display().to_string(),
125 start_line: n.span.start_line,
126 reason: if deg == 0 {
127 "public surface (no inbound calls)".into()
128 } else {
129 format!("central — {deg} inbound calls")
130 },
131 })
132 .collect()
133}
134
135fn seeded_tour(
137 by_id: &HashMap<String, Node>,
138 callees_of: &HashMap<String, Vec<String>>,
139 in_degree: &HashMap<String, u32>,
140 seed_name: &str,
141 limit: usize,
142) -> Vec<TourStep> {
143 let seed_node = by_id
147 .values()
148 .filter(|n| n.name == seed_name)
149 .max_by_key(|n| in_degree.get(&n.id.as_str()).copied().unwrap_or(0));
150 let Some(seed) = seed_node else {
151 return Vec::new();
152 };
153
154 let mut visited: HashSet<String> = HashSet::new();
155 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
156 queue.push_back((seed.id.as_str(), 0));
157 visited.insert(seed.id.as_str());
158
159 let mut steps: Vec<TourStep> = Vec::new();
160 while let Some((id, hop)) = queue.pop_front() {
161 if steps.len() >= limit {
162 break;
163 }
164 let Some(n) = by_id.get(&id) else { continue };
165 let reason = if hop == 0 {
166 "seed".into()
167 } else if hop == 1 {
168 "directly called by seed".into()
169 } else {
170 format!("{hop} hops from seed")
171 };
172 steps.push(TourStep {
173 order: (steps.len() + 1) as u32,
174 name: n.name.clone(),
175 qualified_name: n.qualified_name.clone(),
176 kind: n.kind.to_string(),
177 file: n.file.display().to_string(),
178 start_line: n.span.start_line,
179 reason,
180 });
181 if let Some(next) = callees_of.get(&id) {
182 for callee_id in next {
183 if visited.insert(callee_id.clone()) {
184 queue.push_back((callee_id.clone(), hop + 1));
185 }
186 }
187 }
188 }
189
190 steps
191}
192
193pub fn render_markdown(tour: &Tour) -> String {
195 use std::fmt::Write;
196 let mut out = String::with_capacity(512);
197 let _ = writeln!(
198 out,
199 "# Tour ({} steps, branch={})",
200 tour.steps.len(),
201 tour.branch
202 );
203 if let Some(seed) = &tour.seed {
204 let _ = writeln!(out, "Seed: `{seed}`");
205 }
206 let _ = writeln!(out);
207 for s in &tour.steps {
208 let _ = writeln!(
209 out,
210 "{}. `{}` ({}) — `{}:{}` _{}_",
211 s.order, s.name, s.kind, s.file, s.start_line, s.reason
212 );
213 }
214 out
215}