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 | NodeKind::Method | NodeKind::Struct | NodeKind::Trait
98 )
99 })
100 .map(|n| {
101 let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
102 (n, deg)
103 })
104 .collect();
105 scored.sort_by(|a, b| {
107 b.1.cmp(&a.1)
108 .then_with(|| a.0.qualified_name.cmp(&b.0.qualified_name))
109 });
110
111 scored
112 .into_iter()
113 .take(limit)
114 .enumerate()
115 .map(|(i, (n, deg))| TourStep {
116 order: (i + 1) as u32,
117 name: n.name.clone(),
118 qualified_name: n.qualified_name.clone(),
119 kind: n.kind.to_string(),
120 file: n.file.display().to_string(),
121 start_line: n.span.start_line,
122 reason: if deg == 0 {
123 "public surface (no inbound calls)".into()
124 } else {
125 format!("central — {deg} inbound calls")
126 },
127 })
128 .collect()
129}
130
131fn seeded_tour(
133 by_id: &HashMap<String, Node>,
134 callees_of: &HashMap<String, Vec<String>>,
135 in_degree: &HashMap<String, u32>,
136 seed_name: &str,
137 limit: usize,
138) -> Vec<TourStep> {
139 let seed_node = by_id
143 .values()
144 .filter(|n| n.name == seed_name)
145 .max_by_key(|n| in_degree.get(&n.id.as_str()).copied().unwrap_or(0));
146 let Some(seed) = seed_node else {
147 return Vec::new();
148 };
149
150 let mut visited: HashSet<String> = HashSet::new();
151 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
152 queue.push_back((seed.id.as_str(), 0));
153 visited.insert(seed.id.as_str());
154
155 let mut steps: Vec<TourStep> = Vec::new();
156 while let Some((id, hop)) = queue.pop_front() {
157 if steps.len() >= limit {
158 break;
159 }
160 let Some(n) = by_id.get(&id) else { continue };
161 let reason = if hop == 0 {
162 "seed".into()
163 } else if hop == 1 {
164 "directly called by seed".into()
165 } else {
166 format!("{hop} hops from seed")
167 };
168 steps.push(TourStep {
169 order: (steps.len() + 1) as u32,
170 name: n.name.clone(),
171 qualified_name: n.qualified_name.clone(),
172 kind: n.kind.to_string(),
173 file: n.file.display().to_string(),
174 start_line: n.span.start_line,
175 reason,
176 });
177 if let Some(next) = callees_of.get(&id) {
178 for callee_id in next {
179 if visited.insert(callee_id.clone()) {
180 queue.push_back((callee_id.clone(), hop + 1));
181 }
182 }
183 }
184 }
185
186 steps
187}
188
189pub fn render_markdown(tour: &Tour) -> String {
191 use std::fmt::Write;
192 let mut out = String::with_capacity(512);
193 let _ = writeln!(
194 out,
195 "# Tour ({} steps, branch={})",
196 tour.steps.len(),
197 tour.branch
198 );
199 if let Some(seed) = &tour.seed {
200 let _ = writeln!(out, "Seed: `{seed}`");
201 }
202 let _ = writeln!(out);
203 for s in &tour.steps {
204 let _ = writeln!(
205 out,
206 "{}. `{}` ({}) — `{}:{}` _{}_",
207 s.order, s.name, s.kind, s.file, s.start_line, s.reason
208 );
209 }
210 out
211}