Skip to main content

gitcortex_mcp/mcp/
tour.rs

1//! Guided-tour generation — deterministic graph traversal that picks the
2//! "important" symbols of a repo (or of a seeded subgraph) and orders them so
3//! a reader can walk through the codebase top-down.
4//!
5//! Algorithm (pure graph, no LLM):
6//! 1. Build adjacency from `Contains` + `Calls` edges.
7//! 2. Score each node by in-degree across `Calls` (centrality).
8//! 3. Pick top-K nodes globally, or BFS from a seed when one is provided.
9//! 4. Return ordered tour steps with rationale per step.
10
11use 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/// One step in a generated tour.
24#[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    /// Why this step appears here — e.g. "high in-degree (12 callers)" or
33    /// "entry point: public function" or "called by previous step".
34    pub reason: String,
35}
36
37/// A component (directory/module) in the architecture summary.
38#[derive(Debug, Clone, Serialize)]
39pub struct Component {
40    /// Directory path that groups the component's files, e.g. `src/parser`.
41    pub path: String,
42    /// Number of distinct source files in the component.
43    pub files: u32,
44    /// Highest-centrality public symbols in the component (up to 4).
45    pub key_symbols: Vec<String>,
46    /// Other components this one calls into / uses / imports from.
47    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    /// Component-level architecture summary (no-seed tours only). Answers
56    /// "what are the main components and how do they fit together" in one call.
57    pub components: Vec<Component>,
58}
59
60/// Default tour length when caller doesn't specify.
61const DEFAULT_TOUR_LEN: usize = 12;
62/// Hard cap to keep tour outputs bounded.
63const MAX_TOUR_LEN: usize = 50;
64/// No-seed tours lead with the component map; the global central-symbol list is
65/// kept short so the result is a compact, self-contained overview.
66const NO_SEED_STEP_CAP: usize = 5;
67
68/// Generate a tour for `branch`. If `seed` is `Some`, the tour is rooted at
69/// that symbol and walks outward via `Calls` and `Contains` edges. If `None`,
70/// the tour picks the highest-centrality public entry points across the repo.
71pub 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    // Cross-component dependency edges (Calls/Uses/Imports), kept for the
93    // architecture summary so we can show how components fit together.
94    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            // No-seed: the component map is the answer. Keep only a short
110            // "most central overall" list (top 5) so the payload stays small —
111            // per-component key symbols already cover the important ones.
112            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
125/// Derive a component label from a file path: the parent directory, or the
126/// stem when the file is at the repo root.
127fn component_of(file: &str) -> String {
128    match file.rfind('/') {
129        Some(i) => file[..i].to_owned(),
130        None => file.to_owned(),
131    }
132}
133
134/// Group symbols into components (directories) and summarise each: file count,
135/// top central symbols, and the other components it depends on. Components are
136/// ranked by aggregate centrality so the most important appear first.
137fn 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    // id → component, for edge resolution.
144    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    // Per-component aggregates.
150    let mut files: HashMap<String, HashSet<String>> = HashMap::new();
151    let mut score: HashMap<String, u32> = HashMap::new();
152    // (symbol_name "name — file:line", degree) for picking key symbols. The
153    // location is embedded so a tour answer needs no follow-up lookups.
154    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    // Cross-component dependencies.
175    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
210/// Pick highest-centrality public functions/methods across the repo.
211fn 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    // Sort: higher degree first, then alphabetic by qualified_name for determinism.
234    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
259/// BFS from `seed_name` along `Calls`, preserving discovery order.
260fn 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    // Find a seed node by unqualified name; pick the highest-centrality one
268    // when multiple match (matches user intent — "tour main" picks the
269    // central main).
270    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
317/// Render a tour as a human-readable markdown plan.
318pub fn render_markdown(tour: &Tour) -> String {
319    use std::fmt::Write;
320    let mut out = String::with_capacity(512);
321
322    // No-seed tours ARE the component-level architecture map — a compact,
323    // self-contained answer to "what are the main components and how do they
324    // fit together". A short "most central" list follows; we deliberately do
325    // not dump a long step list, keeping the result token-cheap.
326    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}