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::{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/// 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    /// Community group label (no-seed tours only). `None` for seeded tours.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub community: Option<String>,
38}
39
40/// A component (directory/module) in the architecture summary.
41#[derive(Debug, Clone, Serialize)]
42pub struct Component {
43    /// Directory path that groups the component's files, e.g. `src/parser`.
44    pub path: String,
45    /// Number of distinct source files in the component.
46    pub files: u32,
47    /// Highest-ranked public production symbols in the component (up to 2).
48    pub key_symbols: Vec<String>,
49    /// Other components this one calls into / uses / imports from.
50    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    /// Component-level architecture summary (no-seed tours only). Answers
59    /// "what are the main components and how do they fit together" in one call.
60    pub components: Vec<Component>,
61}
62
63/// Default tour length when caller doesn't specify.
64const DEFAULT_TOUR_LEN: usize = 6;
65/// Hard cap to keep tour outputs bounded.
66const MAX_TOUR_LEN: usize = 20;
67/// No-seed tours retain a small internal entry-point head alongside components.
68const NO_SEED_STEP_CAP: usize = 8;
69
70/// Generate a tour for `branch`. If `seed` is `Some`, the tour is rooted at
71/// that symbol and walks outward via `Calls` and `Contains` edges. If `None`,
72/// the tour picks the highest-centrality public entry points across the repo.
73pub 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    // Cross-component dependency edges (Calls/Uses/Imports), kept for the
95    // architecture summary so we can show how components fit together.
96    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
126/// Derive a component label from a file path: the parent directory, or the
127/// stem when the file is at the repo root.
128fn 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    // Very common helper names can accumulate noisy inferred edges. Cap the
167    // centrality contribution so architecture-bearing types and entry
168    // functions remain ahead of generic methods such as `get` or `as_str`.
169    kind_weight + in_degree.min(30)
170}
171
172/// Group symbols into components (directories) and summarise each: file count,
173/// top central symbols, and the other components it depends on. Components are
174/// ranked by aggregate centrality so the most important appear first.
175fn 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    // id → component, for edge resolution.
182    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    // Per-component aggregates.
189    let mut files: HashMap<String, HashSet<String>> = HashMap::new();
190    let mut score: HashMap<String, u32> = HashMap::new();
191    // (symbol_name "name — file:line", degree) for picking key symbols. The
192    // location is embedded so a tour answer needs no follow-up lookups.
193    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    // Cross-component dependencies.
218    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
254/// Pick architecture-bearing public production symbols across the repo.
255fn 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    // Sort: architecture-weighted score first, then qualified name.
269    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
295/// BFS from `seed_name` along `Calls`, preserving discovery order.
296fn 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    // Find a seed node by unqualified name; pick the highest-centrality one
304    // when multiple match (matches user intent — "tour main" picks the
305    // central main).
306    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
354/// Render a tour as a human-readable markdown plan.
355pub fn render_markdown(tour: &Tour) -> String {
356    use std::fmt::Write;
357    let mut out = String::with_capacity(512);
358
359    // No-seed tours ARE the component-level architecture map — a compact,
360    // self-contained answer to "what are the main components and how do they
361    // fit together". A short "most central" list follows; we deliberately do
362    // not dump a long step list, keeping the result token-cheap.
363    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}