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
21/// One step in a generated tour.
22#[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    /// Why this step appears here — e.g. "high in-degree (12 callers)" or
31    /// "entry point: public function" or "called by previous step".
32    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
42/// Default tour length when caller doesn't specify.
43const DEFAULT_TOUR_LEN: usize = 12;
44/// Hard cap to keep tour outputs bounded.
45const MAX_TOUR_LEN: usize = 50;
46
47/// Generate a tour for `branch`. If `seed` is `Some`, the tour is rooted at
48/// that symbol and walks outward via `Calls` and `Contains` edges. If `None`,
49/// the tour picks the highest-centrality public entry points across the repo.
50pub 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
86/// Pick highest-centrality public functions/methods across the repo.
87fn 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    // Sort: higher degree first, then alphabetic by qualified_name for determinism.
110    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
135/// BFS from `seed_name` along `Calls`, preserving discovery order.
136fn 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    // Find a seed node by unqualified name; pick the highest-centrality one
144    // when multiple match (matches user intent — "tour main" picks the
145    // central main).
146    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
193/// Render a tour as a human-readable markdown plan.
194pub 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}