Skip to main content

gitcortex_mcp/mcp/
tools.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex};
3
4use gitcortex_core::{
5    schema::{NodeKind, Visibility},
6    store::{AttributeFilter, GraphStore},
7};
8use gitcortex_store::kuzu::KuzuGraphStore;
9
10use crate::embeddings::{Embedder, SemanticIndex};
11
12pub enum SemanticState {
13    /// Background initialiser not done yet — search is text-only.
14    Pending,
15    /// Model loaded and index populated.
16    Ready {
17        embedder: Box<Embedder>,
18        index: Box<SemanticIndex>,
19    },
20    /// Initialisation failed (no network, disk error, etc.) — text-only forever.
21    Disabled,
22}
23use rmcp::{
24    handler::server::router::tool::ToolRouter,
25    handler::server::wrapper::Parameters,
26    model::{
27        CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
28        PaginatedRequestParams, PromptMessage, PromptMessageRole,
29    },
30    prompt, prompt_handler, prompt_router,
31    service::RequestContext,
32    tool, tool_handler, tool_router, RoleServer,
33};
34use schemars::JsonSchema;
35use serde::Deserialize;
36use serde_json::json;
37
38// ── Parameter types ───────────────────────────────────────────────────────────
39
40#[derive(Debug, Deserialize, JsonSchema)]
41pub struct GcxDispatchParams {
42    /// Which graph operation to run. One of: lookup_symbol, find_callers, find_callees,
43    /// find_unused_symbols, get_subgraph, search_code, start_tour, wiki_symbol,
44    /// trace_path, list_definitions, symbol_context, list_symbols_in_range, graph_stats,
45    /// ast_search, type_hierarchy, find_importers, find_type_usages, module_dependencies,
46    /// get_call_sites.
47    pub action: String,
48    /// Parameters for the chosen action as a JSON object (same fields as the
49    /// individual tool: name, function_name, seed_name, query, file, branch,
50    /// depth, limit, direction, src, dst, start_line, end_line).
51    pub params: serde_json::Value,
52}
53
54#[derive(Debug, Deserialize, JsonSchema)]
55pub struct LookupSymbolParams {
56    /// Symbol name to search for (unqualified).
57    pub name: String,
58    /// When true, matches any symbol whose name *contains* `name` (substring).
59    /// When false (default), exact match only.
60    pub fuzzy: Option<bool>,
61    /// Branch name (defaults to "main" if omitted).
62    pub branch: Option<String>,
63}
64
65#[derive(Debug, Deserialize, JsonSchema)]
66pub struct FindCallersParams {
67    /// Name of the function/method to find callers of.
68    pub function_name: String,
69    /// How many hops to walk up the call graph (1–5, default 1).
70    /// depth=1 returns direct callers only. depth=3 walks three levels.
71    pub depth: Option<u8>,
72    pub branch: Option<String>,
73}
74
75#[derive(Debug, Deserialize, JsonSchema)]
76pub struct SymbolContextParams {
77    /// Symbol name to look up (unqualified).
78    pub name: String,
79    /// Branch name (defaults to current branch if omitted).
80    pub branch: Option<String>,
81}
82
83#[derive(Debug, Deserialize, JsonSchema)]
84pub struct ListDefinitionsParams {
85    /// Repo-relative path to a source file.
86    pub file: String,
87    pub branch: Option<String>,
88}
89
90#[derive(Debug, Deserialize, JsonSchema)]
91pub struct BranchDiffParams {
92    pub from_branch: String,
93    pub to_branch: String,
94}
95
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct DetectChangesParams {
98    /// Branch to query (defaults to "main" if omitted).
99    pub branch: Option<String>,
100}
101
102#[derive(Debug, Deserialize, JsonSchema)]
103pub struct FindCalleesParams {
104    /// Name of the function/method to trace callees of.
105    pub function_name: String,
106    /// How many hops to walk forward in the call graph (1–5, default 1).
107    pub depth: Option<u8>,
108    pub branch: Option<String>,
109}
110
111#[derive(Debug, Deserialize, JsonSchema)]
112pub struct FindImplementorsParams {
113    /// Trait, interface, or abstract class name to find implementors of.
114    pub trait_name: String,
115    pub branch: Option<String>,
116}
117
118#[derive(Debug, Deserialize, JsonSchema)]
119pub struct TypeHierarchyParams {
120    /// Type name (struct/class/trait/interface) to map relationships for.
121    pub name: String,
122    pub branch: Option<String>,
123}
124
125#[derive(Debug, Deserialize, JsonSchema)]
126pub struct FindImportersParams {
127    /// Symbol name to find importers of (the imported thing, unqualified).
128    pub name: String,
129    pub branch: Option<String>,
130}
131
132#[derive(Debug, Deserialize, JsonSchema)]
133pub struct GetCallSitesParams {
134    /// Function/method name to find call sites of.
135    pub name: String,
136    pub branch: Option<String>,
137}
138
139#[derive(Debug, Deserialize, JsonSchema)]
140pub struct FindTypeUsagesParams {
141    /// Type name (struct/class/trait/interface/enum) to find usages of.
142    pub name: String,
143    pub branch: Option<String>,
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct ModuleDependenciesParams {
148    /// Module name (file stem, e.g. "tools" for tools.rs) to list dependencies of.
149    pub name: String,
150    pub branch: Option<String>,
151}
152
153#[derive(Debug, Deserialize, JsonSchema)]
154pub struct TracePathParams {
155    /// Starting function/method name.
156    pub from: String,
157    /// Target function/method name.
158    pub to: String,
159    pub branch: Option<String>,
160}
161
162#[derive(Debug, Deserialize, JsonSchema)]
163pub struct ListSymbolsInRangeParams {
164    /// Repo-relative path to a source file.
165    pub file: String,
166    /// Start line of the range (1-indexed, inclusive).
167    pub start_line: u32,
168    /// End line of the range (1-indexed, inclusive).
169    pub end_line: u32,
170    pub branch: Option<String>,
171}
172
173#[derive(Debug, Deserialize, JsonSchema)]
174pub struct FindUnusedSymbolsParams {
175    /// Optional NodeKind filter: "function", "method", "struct", etc.
176    pub kind: Option<String>,
177    /// Max symbols returned (default 30, capped at 200). `count` always reports
178    /// the true total; `truncated` flags when the list was longer.
179    pub limit: Option<usize>,
180    pub branch: Option<String>,
181}
182
183#[derive(Debug, Deserialize, JsonSchema)]
184pub struct GetSubgraphParams {
185    /// Seed symbol name (unqualified).
186    pub seed_name: String,
187    /// How many hops to expand from the seed (1–5, default 1). Depth 2+ on a
188    /// high-degree hub returns a large subgraph — raise deliberately.
189    pub depth: Option<u8>,
190    /// Direction: "in" (callers/ancestors), "out" (callees/descendants), "both" (default).
191    pub direction: Option<String>,
192    /// Max nodes returned (default 30, capped at 200). Edges are filtered to the
193    /// kept node set; `truncated` flags when the neighbourhood was larger.
194    pub limit: Option<usize>,
195    pub branch: Option<String>,
196}
197
198#[derive(Debug, Deserialize, JsonSchema)]
199pub struct WikiSymbolParams {
200    /// Symbol to summarise (unqualified name).
201    pub name: String,
202    pub branch: Option<String>,
203}
204
205#[derive(Debug, Deserialize, JsonSchema)]
206pub struct SearchCodeParams {
207    /// Free-text query — substring matched against `name` and `qualified_name`.
208    pub query: String,
209    /// Max results (default 10, capped at 200).
210    pub limit: Option<usize>,
211    pub branch: Option<String>,
212}
213
214#[derive(Debug, Deserialize, JsonSchema)]
215pub struct StartTourParams {
216    /// Optional seed symbol — when given, the tour walks outward from it
217    /// along the call graph. When omitted, picks the highest-centrality
218    /// entry points across the repo.
219    pub seed: Option<String>,
220    /// How many steps in the tour (default 12, capped at 50).
221    pub limit: Option<usize>,
222    pub branch: Option<String>,
223}
224
225#[derive(Debug, Deserialize, JsonSchema)]
226pub struct GraphStatsParams {
227    /// Branch to summarise (defaults to current branch if omitted).
228    pub branch: Option<String>,
229}
230
231#[derive(Debug, Deserialize, JsonSchema)]
232pub struct AstSearchParams {
233    /// NodeKind filter: "function", "method", "struct", "trait", "interface",
234    /// "enum", "constant", "type_alias", "module", etc. Omit for any kind.
235    pub kind: Option<String>,
236    /// When set, match only async (true) or only non-async (false) symbols.
237    pub is_async: Option<bool>,
238    /// Visibility filter: "pub", "pub_crate", or "private".
239    pub visibility: Option<String>,
240    /// Inclusive lower bound on cyclomatic complexity. Symbols without a
241    /// recorded complexity are excluded when this is set.
242    pub min_complexity: Option<u32>,
243    /// Inclusive upper bound on cyclomatic complexity.
244    pub max_complexity: Option<u32>,
245    /// Case-insensitive substring the symbol name must contain.
246    pub name_contains: Option<String>,
247    /// Annotation/decorator name the symbol must carry (case-insensitive
248    /// substring): "Test" finds `@Test`, "route" finds `@app.route`,
249    /// "derive" finds `#[derive(...)]`.
250    pub annotation: Option<String>,
251    /// Max results (default 30, capped at 200).
252    pub limit: Option<usize>,
253    pub branch: Option<String>,
254}
255
256// ── Server ────────────────────────────────────────────────────────────────────
257
258/// The MCP server handler. One shared `KuzuGraphStore` wrapped in `Arc<Mutex>`
259/// so all handler calls can share state safely.
260#[derive(Clone)]
261pub struct GitCortexServer {
262    store: Arc<Mutex<KuzuGraphStore>>,
263    repo_root: PathBuf,
264    default_branch: String,
265    compact: bool,
266    /// Approximate token budget for a single tool's list payload. List-returning
267    /// tools truncate their items to fit this, setting `truncated: true`, so a
268    /// high-fan-out symbol can never make the graph arm dump more than a grep
269    /// would read. Configurable via `GCX_RESPONSE_BUDGET` (token count).
270    response_budget: usize,
271    /// Semantic search state. Starts as `Pending`; background task flips to
272    /// `Ready` once the model is loaded and missing vectors are embedded.
273    /// `Arc<Mutex<…>>` so the background task and all clone'd handler instances
274    /// share the same index.
275    pub semantic: Arc<Mutex<SemanticState>>,
276}
277
278/// Default per-tool response token budget when `GCX_RESPONSE_BUDGET` is unset.
279const DEFAULT_RESPONSE_BUDGET: usize = 2000;
280/// Floor so a misconfigured tiny budget still returns something useful.
281const MIN_RESPONSE_BUDGET: usize = 400;
282
283impl GitCortexServer {
284    pub fn new(repo_root: &Path) -> anyhow::Result<Self> {
285        Self::new_with_mode(repo_root, false)
286    }
287
288    pub fn new_with_mode(repo_root: &Path, compact: bool) -> anyhow::Result<Self> {
289        let store = KuzuGraphStore::open(repo_root)?;
290        let default_branch = detect_current_branch(repo_root).unwrap_or_else(|| "main".into());
291        let response_budget = std::env::var("GCX_RESPONSE_BUDGET")
292            .ok()
293            .and_then(|s| s.parse::<usize>().ok())
294            .unwrap_or(DEFAULT_RESPONSE_BUDGET)
295            .max(MIN_RESPONSE_BUDGET);
296        Ok(Self {
297            store: Arc::new(Mutex::new(store)),
298            repo_root: repo_root.to_owned(),
299            default_branch,
300            compact,
301            response_budget,
302            semantic: Arc::new(Mutex::new(SemanticState::Pending)),
303        })
304    }
305
306    /// Truncate a list of JSON items to fit `response_budget`, returning the
307    /// kept items and whether truncation occurred. Token size is estimated as
308    /// serialized bytes / 4 (the usual rule of thumb) — cheap and good enough
309    /// to bound payloads. Always keeps at least one item so a single large
310    /// result is never dropped to nothing.
311    fn budget_items(&self, items: Vec<serde_json::Value>) -> (Vec<serde_json::Value>, bool) {
312        let budget_bytes = self.response_budget * 4;
313        let mut kept: Vec<serde_json::Value> = Vec::with_capacity(items.len());
314        let mut used = 0usize;
315        let total = items.len();
316        for item in items {
317            let sz = item.to_string().len() + 2; // +2 for ", " separators
318            if !kept.is_empty() && used + sz > budget_bytes {
319                break;
320            }
321            used += sz;
322            kept.push(item);
323        }
324        let truncated = kept.len() < total;
325        (kept, truncated)
326    }
327
328    /// Return the shared arcs + branch needed by the background semantic indexer.
329    pub fn semantic_context(
330        &self,
331    ) -> (
332        Arc<Mutex<SemanticState>>,
333        Arc<Mutex<KuzuGraphStore>>,
334        String,
335    ) {
336        (
337            self.semantic.clone(),
338            self.store.clone(),
339            self.default_branch.clone(),
340        )
341    }
342
343    fn active_tool_router(&self) -> ToolRouter<Self> {
344        let mut router = Self::tool_router();
345        if self.compact {
346            for name in [
347                "lookup_symbol",
348                "find_callers",
349                "symbol_context",
350                "list_definitions",
351                "branch_diff_graph",
352                "detect_changes",
353                "find_callees",
354                "find_implementors",
355                "trace_path",
356                "list_symbols_in_range",
357                "find_unused_symbols",
358                "get_subgraph",
359                "wiki_symbol",
360                "search_code",
361                "start_tour",
362            ] {
363                router.disable_route(name);
364            }
365        }
366        router
367    }
368}
369
370/// First line of a node's captured signature, trimmed and length-capped, for
371/// embedding in tool results so the model can judge a symbol without opening
372/// its file. Empty string when no signature was captured.
373fn sig_line(n: &gitcortex_core::graph::Node) -> String {
374    const MAX: usize = 120;
375    let first = n
376        .metadata
377        .definition
378        .signature
379        .lines()
380        .next()
381        .unwrap_or("")
382        .trim();
383    if first.chars().count() > MAX {
384        let truncated: String = first.chars().take(MAX).collect();
385        format!("{truncated}…")
386    } else {
387        first.to_owned()
388    }
389}
390
391/// Parse a NodeKind from its snake_case string form (matches `NodeKind::Display`).
392fn parse_node_kind(s: &str) -> Option<NodeKind> {
393    Some(match s {
394        "folder" => NodeKind::Folder,
395        "file" => NodeKind::File,
396        "module" => NodeKind::Module,
397        "struct" => NodeKind::Struct,
398        "enum" => NodeKind::Enum,
399        "trait" => NodeKind::Trait,
400        "interface" => NodeKind::Interface,
401        "type_alias" => NodeKind::TypeAlias,
402        "function" => NodeKind::Function,
403        "method" => NodeKind::Method,
404        "property" => NodeKind::Property,
405        "constant" => NodeKind::Constant,
406        "macro" => NodeKind::Macro,
407        "annotation" => NodeKind::Annotation,
408        "enum_member" => NodeKind::EnumMember,
409        _ => return None,
410    })
411}
412
413/// Parse a Visibility from its snake_case string form.
414fn parse_visibility(s: &str) -> Option<Visibility> {
415    Some(match s {
416        "pub" => Visibility::Pub,
417        "pub_crate" => Visibility::PubCrate,
418        "private" => Visibility::Private,
419        _ => return None,
420    })
421}
422
423fn detect_current_branch(repo_root: &Path) -> Option<String> {
424    let out = std::process::Command::new("git")
425        .args(["symbolic-ref", "--short", "HEAD"])
426        .current_dir(repo_root)
427        .output()
428        .ok()?;
429    if out.status.success() {
430        let s = String::from_utf8(out.stdout).ok()?;
431        let b = s.trim().to_owned();
432        if b.is_empty() {
433            None
434        } else {
435            Some(b)
436        }
437    } else {
438        None
439    }
440}
441
442// ── Tool implementations ──────────────────────────────────────────────────────
443
444#[tool_router]
445impl GitCortexServer {
446    /// Look up all nodes (functions, structs, traits, etc.) by name.
447    #[tool(
448        description = "Look up nodes in the code knowledge graph by name. Set fuzzy=true for substring matching (e.g. 'auth' finds 'validate_auth', 'auth_middleware'). Default is exact match."
449    )]
450    fn lookup_symbol(&self, Parameters(p): Parameters<LookupSymbolParams>) -> CallToolResult {
451        let branch = p
452            .branch
453            .as_deref()
454            .unwrap_or(&self.default_branch)
455            .to_owned();
456        let fuzzy = p.fuzzy.unwrap_or(false);
457        let store = match self.store.lock() {
458            Ok(g) => g,
459            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
460        };
461        match store.lookup_symbol(&branch, &p.name, fuzzy) {
462            Ok(nodes) => {
463                let items: Vec<_> = nodes
464                    .iter()
465                    .map(|n| {
466                        json!({
467                            "id": n.id.as_str(),
468                            "kind": n.kind.to_string(),
469                            "name": n.name,
470                            "qualified_name": n.qualified_name,
471                            "file": n.file.display().to_string(),
472                            "start_line": n.span.start_line,
473                            "end_line": n.span.end_line,
474                            "visibility": format!("{:?}", n.metadata.visibility),
475                            "is_async": n.metadata.is_async,
476                            "is_unsafe": n.metadata.is_unsafe,
477                        })
478                    })
479                    .collect();
480                let (items, _) = self.budget_items(items);
481                CallToolResult::structured(json!(items))
482            }
483            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
484        }
485    }
486
487    /// Find all callers of a function or method, with optional multi-hop depth.
488    #[tool(
489        description = "Find callers of a function. depth=1 (default) = direct callers; \
490        depth=2..5 = multi-hop. Results capped per hop; total count always returned."
491    )]
492    fn find_callers(&self, Parameters(p): Parameters<FindCallersParams>) -> CallToolResult {
493        let branch = p
494            .branch
495            .as_deref()
496            .unwrap_or(&self.default_branch)
497            .to_owned();
498        let depth = p.depth.unwrap_or(1).max(1);
499        let store = match self.store.lock() {
500            Ok(g) => g,
501            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
502        };
503
504        // Cap the caller list. The risk level is computed from the true total,
505        // so a hub symbol still reports CRITICAL even though we return a head.
506        const MAX_CALLERS: usize = 25;
507        const MAX_PER_HOP: usize = 15;
508        if depth == 1 {
509            match store.find_callers(&branch, &p.function_name) {
510                Ok(nodes) => {
511                    let total = nodes.len();
512                    let items: Vec<_> = nodes
513                        .iter()
514                        .take(MAX_CALLERS)
515                        .map(|n| {
516                            json!({
517                                "hop": 1,
518                                "kind": n.kind.to_string(),
519                                "name": n.name,
520                                "qualified_name": n.qualified_name,
521                                "file": n.file.display().to_string(),
522                                "start_line": n.span.start_line,
523                                // Signature lets the model judge impact without
524                                // opening the caller's file — the biggest token
525                                // sink on refactor-impact questions.
526                                "signature": sig_line(n),
527                            })
528                        })
529                        .collect();
530                    let (items, budget_trunc) = self.budget_items(items);
531                    let risk = match total {
532                        0..=2 => "LOW",
533                        3..=10 => "MEDIUM",
534                        11..=30 => "HIGH",
535                        _ => "CRITICAL",
536                    };
537                    CallToolResult::structured(json!({
538                        "summary": format!("{total} caller(s) — risk {risk}{}",
539                            if total > items.len() {
540                                format!(", showing top {}", items.len())
541                            } else { String::new() }),
542                        "function": p.function_name,
543                        "depth": 1,
544                        "risk_level": risk,
545                        "total_callers": total,
546                        "returned": items.len(),
547                        "truncated": total > items.len() || budget_trunc,
548                        "callers": items,
549                    }))
550                }
551                Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
552            }
553        } else {
554            match store.find_callers_deep(&branch, &p.function_name, depth) {
555                Ok(result) => {
556                    let hops: Vec<_> = result
557                        .hops
558                        .iter()
559                        .enumerate()
560                        .map(|(i, nodes)| {
561                            let total = nodes.len();
562                            let callers: Vec<_> = nodes
563                                .iter()
564                                .take(MAX_PER_HOP)
565                                .map(|n| {
566                                    json!({
567                                        "kind": n.kind.to_string(),
568                                        "name": n.name,
569                                        "qualified_name": n.qualified_name,
570                                        "file": n.file.display().to_string(),
571                                        "start_line": n.span.start_line,
572                                        "signature": sig_line(n),
573                                    })
574                                })
575                                .collect();
576                            json!({
577                                "hop": i + 1,
578                                "total": total,
579                                "truncated": total > MAX_PER_HOP,
580                                "callers": callers,
581                            })
582                        })
583                        .collect();
584                    CallToolResult::structured(json!({
585                        "function": p.function_name,
586                        "depth": depth,
587                        "risk_level": result.risk_level,
588                        "hops": hops,
589                    }))
590                }
591                Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
592            }
593        }
594    }
595
596    /// Get a 360° view of a symbol: definition, callers, callees, and type usages.
597    #[tool(
598        description = "Get a complete picture of a symbol in one call: where it's defined, \
599        what calls it (callers), what it calls (callees), and which code references it as a type. \
600        Use this instead of chaining lookup_symbol + find_callers separately."
601    )]
602    fn symbol_context(&self, Parameters(p): Parameters<SymbolContextParams>) -> CallToolResult {
603        let branch = p
604            .branch
605            .as_deref()
606            .unwrap_or(&self.default_branch)
607            .to_owned();
608        let store = match self.store.lock() {
609            Ok(g) => g,
610            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
611        };
612        match store.symbol_context(&branch, &p.name) {
613            Ok(ctx) => {
614                let node_json = |n: &gitcortex_core::graph::Node| {
615                    json!({
616                        "kind": n.kind.to_string(),
617                        "name": n.name,
618                        "qualified_name": n.qualified_name,
619                        "file": n.file.display().to_string(),
620                        "start_line": n.span.start_line,
621                    })
622                };
623                CallToolResult::structured(json!({
624                    "definition": {
625                        "kind": ctx.definition.kind.to_string(),
626                        "name": ctx.definition.name,
627                        "qualified_name": ctx.definition.qualified_name,
628                        "file": ctx.definition.file.display().to_string(),
629                        "start_line": ctx.definition.span.start_line,
630                        "end_line": ctx.definition.span.end_line,
631                        "visibility": format!("{:?}", ctx.definition.metadata.visibility),
632                        "is_async": ctx.definition.metadata.is_async,
633                        "complexity": ctx.definition.metadata.lld.complexity,
634                    },
635                    "callers": ctx.callers.iter().map(node_json).collect::<Vec<_>>(),
636                    "callees": ctx.callees.iter().map(node_json).collect::<Vec<_>>(),
637                    "used_by": ctx.used_by.iter().map(node_json).collect::<Vec<_>>(),
638                }))
639            }
640            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
641        }
642    }
643
644    /// List all symbols defined in a source file, ordered by line number.
645    #[tool(
646        description = "List all functions, structs, traits, and other definitions in a source file, ordered by line number."
647    )]
648    fn list_definitions(&self, Parameters(p): Parameters<ListDefinitionsParams>) -> CallToolResult {
649        let branch = p
650            .branch
651            .as_deref()
652            .unwrap_or(&self.default_branch)
653            .to_owned();
654        let store = match self.store.lock() {
655            Ok(g) => g,
656            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
657        };
658        match store.list_definitions(&branch, Path::new(&p.file)) {
659            Ok(nodes) => {
660                let items: Vec<_> = nodes
661                    .iter()
662                    .map(|n| {
663                        json!({
664                            "kind": n.kind.to_string(),
665                            "name": n.name,
666                            "qualified_name": n.qualified_name,
667                            "start_line": n.span.start_line,
668                            "end_line": n.span.end_line,
669                            "loc": n.metadata.loc,
670                            "visibility": format!("{:?}", n.metadata.visibility),
671                            "is_async": n.metadata.is_async,
672                        })
673                    })
674                    .collect();
675                let (items, _) = self.budget_items(items);
676                CallToolResult::structured(json!(items))
677            }
678            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
679        }
680    }
681
682    /// Aggregate counts for the branch's graph — orientation before exploring.
683    #[tool(
684        description = "Get aggregate counts for the code graph: total nodes/edges plus per-kind breakdowns (how many functions, structs, calls edges, etc). Use this first to gauge codebase size and shape before drilling into specific symbols."
685    )]
686    fn graph_stats(&self, Parameters(p): Parameters<GraphStatsParams>) -> CallToolResult {
687        let branch = p
688            .branch
689            .as_deref()
690            .unwrap_or(&self.default_branch)
691            .to_owned();
692        let store = match self.store.lock() {
693            Ok(g) => g,
694            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
695        };
696        match store.graph_stats(&branch) {
697            Ok(stats) => {
698                let to_obj = |pairs: &[(String, u64)]| -> serde_json::Value {
699                    json!(pairs
700                        .iter()
701                        .map(|(k, c)| json!({ "kind": k, "count": c }))
702                        .collect::<Vec<_>>())
703                };
704                CallToolResult::structured(json!({
705                    "branch": branch,
706                    "total_nodes": stats.total_nodes,
707                    "total_edges": stats.total_edges,
708                    "nodes_by_kind": to_obj(&stats.nodes_by_kind),
709                    "edges_by_kind": to_obj(&stats.edges_by_kind),
710                }))
711            }
712            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
713        }
714    }
715
716    /// Structural search over node attributes (no name needed).
717    #[tool(
718        description = "Find symbols by structural attributes rather than name: kind (function/method/struct/...), is_async, visibility (pub/pub_crate/private), cyclomatic complexity range, and annotation/decorator (e.g. annotation='Test' finds @Test methods, 'route' finds @app.route handlers, 'derive' finds #[derive(...)]). Combine filters to answer 'all async methods', 'public structs', 'functions with complexity ≥ 10', or 'all test functions'. Optional name_contains narrows further. Default limit=30."
719    )]
720    fn ast_search(&self, Parameters(p): Parameters<AstSearchParams>) -> CallToolResult {
721        let branch = p
722            .branch
723            .as_deref()
724            .unwrap_or(&self.default_branch)
725            .to_owned();
726        let limit = p.limit.unwrap_or(30).min(200);
727
728        let kind = p.kind.as_deref().and_then(parse_node_kind);
729        // Reject an unknown kind string rather than silently ignoring it.
730        if p.kind.is_some() && kind.is_none() {
731            return CallToolResult::error(vec![Content::text(format!(
732                "unknown kind '{}'. Valid: function, method, struct, enum, trait, \
733                 interface, type_alias, property, constant, macro, annotation, \
734                 enum_member, module, file, folder",
735                p.kind.as_deref().unwrap_or("")
736            ))]);
737        }
738        let visibility = p.visibility.as_deref().and_then(parse_visibility);
739        if p.visibility.is_some() && visibility.is_none() {
740            return CallToolResult::error(vec![Content::text(
741                "unknown visibility. Valid: pub, pub_crate, private".to_owned(),
742            )]);
743        }
744
745        let filter = AttributeFilter {
746            kind,
747            is_async: p.is_async,
748            visibility,
749            min_complexity: p.min_complexity,
750            max_complexity: p.max_complexity,
751            name_contains: p.name_contains.clone(),
752            annotation: p.annotation.clone(),
753        };
754
755        if filter.is_empty() {
756            return CallToolResult::error(vec![Content::text(
757                "ast_search needs at least one filter (kind, is_async, visibility, \
758                 complexity bound, name_contains, or annotation)"
759                    .to_owned(),
760            )]);
761        }
762
763        let store = match self.store.lock() {
764            Ok(g) => g,
765            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
766        };
767        match store.search_by_attributes(&branch, &filter, limit) {
768            Ok(nodes) => {
769                let items: Vec<_> = nodes
770                    .iter()
771                    .map(|n| {
772                        json!({
773                            "kind": n.kind.to_string(),
774                            "name": n.name,
775                            "qualified_name": n.qualified_name,
776                            "file": n.file.display().to_string(),
777                            "start_line": n.span.start_line,
778                            "visibility": format!("{:?}", n.metadata.visibility),
779                            "is_async": n.metadata.is_async,
780                            "complexity": n.metadata.lld.complexity,
781                            "annotations": n.metadata.annotations,
782                        })
783                    })
784                    .collect();
785                let (items, truncated) = self.budget_items(items);
786                CallToolResult::structured(json!({
787                    "branch": branch,
788                    "results": items,
789                    "returned": items.len(),
790                    "truncated": truncated,
791                }))
792            }
793            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
794        }
795    }
796
797    /// Compute the graph diff between two branches.
798    #[tool(
799        description = "Show what nodes were added or removed between two branches. Useful for understanding what changed in a feature branch vs main."
800    )]
801    fn branch_diff_graph(&self, Parameters(p): Parameters<BranchDiffParams>) -> CallToolResult {
802        let store = match self.store.lock() {
803            Ok(g) => g,
804            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
805        };
806        match store.branch_diff(&p.from_branch, &p.to_branch) {
807            Ok(diff) => {
808                let added: Vec<_> = diff
809                    .added_nodes
810                    .iter()
811                    .map(|n| {
812                        json!({
813                            "kind": n.kind.to_string(),
814                            "name": n.name,
815                            "file": n.file.display().to_string(),
816                            "start_line": n.span.start_line,
817                        })
818                    })
819                    .collect();
820
821                // Resolve removed node IDs to full node objects from the from_branch.
822                let from_nodes = store.list_all_nodes(&p.from_branch).unwrap_or_default();
823                let from_map: std::collections::HashMap<_, _> =
824                    from_nodes.iter().map(|n| (n.id.clone(), n)).collect();
825                let removed: Vec<_> = diff
826                    .removed_node_ids
827                    .iter()
828                    .filter_map(|id| from_map.get(id))
829                    .map(|n| {
830                        json!({
831                            "kind": n.kind.to_string(),
832                            "name": n.name,
833                            "file": n.file.display().to_string(),
834                            "start_line": n.span.start_line,
835                        })
836                    })
837                    .collect();
838
839                CallToolResult::structured(json!({
840                    "from": p.from_branch,
841                    "to": p.to_branch,
842                    "added_nodes": added,
843                    "removed_nodes": removed,
844                }))
845            }
846            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
847        }
848    }
849
850    /// Detect which indexed symbols are affected by current staged (or HEAD) changes.
851    #[tool(
852        description = "Map the current git diff (staged changes, or HEAD diff if nothing is staged) \
853        to the indexed symbol graph. Returns which functions/structs were changed, their direct callers, \
854        and a risk level. Use this before committing to understand blast radius automatically."
855    )]
856    fn detect_changes(&self, Parameters(p): Parameters<DetectChangesParams>) -> CallToolResult {
857        let branch = p
858            .branch
859            .as_deref()
860            .unwrap_or(&self.default_branch)
861            .to_owned();
862
863        let diff_text = run_git_diff(&self.repo_root, &["diff", "--staged"])
864            .filter(|s| !s.trim().is_empty())
865            .or_else(|| run_git_diff(&self.repo_root, &["diff", "HEAD"]))
866            .unwrap_or_default();
867
868        if diff_text.trim().is_empty() {
869            return CallToolResult::success(vec![Content::text(
870                "No staged or unstaged changes detected.",
871            )]);
872        }
873
874        let hunks = parse_diff_hunks(&diff_text);
875        let store = match self.store.lock() {
876            Ok(g) => g,
877            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
878        };
879
880        let mut changed_symbols: Vec<serde_json::Value> = Vec::new();
881        let mut total_affected: usize = 0;
882
883        for (file_path, ranges) in &hunks {
884            let path = PathBuf::from(file_path);
885            let definitions = match store.list_definitions(&branch, &path) {
886                Ok(d) => d,
887                Err(_) => continue,
888            };
889            for node in &definitions {
890                let overlaps = ranges
891                    .iter()
892                    .any(|(s, e)| node.span.start_line <= *e && node.span.end_line >= *s);
893                if !overlaps {
894                    continue;
895                }
896                let callers = store.find_callers(&branch, &node.name).unwrap_or_default();
897                let caller_names: Vec<&str> = callers.iter().map(|c| c.name.as_str()).collect();
898                total_affected += 1 + caller_names.len();
899                changed_symbols.push(json!({
900                    "kind": node.kind.to_string(),
901                    "name": node.name,
902                    "file": file_path,
903                    "start_line": node.span.start_line,
904                    "end_line": node.span.end_line,
905                    "callers": caller_names,
906                }));
907            }
908        }
909
910        if changed_symbols.is_empty() {
911            return CallToolResult::success(vec![Content::text(
912                "Changed lines do not overlap with any indexed symbols.",
913            )]);
914        }
915
916        let risk_level = match total_affected {
917            0..=5 => "LOW",
918            6..=20 => "MEDIUM",
919            21..=50 => "HIGH",
920            _ => "CRITICAL",
921        };
922
923        CallToolResult::structured(json!({
924            "risk_level": risk_level,
925            "total_affected": total_affected,
926            "changed_symbols": changed_symbols,
927        }))
928    }
929
930    /// Find all callees of a function/method, tracing forward through the call graph.
931    #[tool(
932        description = "Find all functions/methods that the named function calls. \
933        Inverse of find_callers — traces forward (downstream). Use depth=1..5 to walk multiple hops. \
934        Returns callees grouped by hop distance."
935    )]
936    fn find_callees(&self, Parameters(p): Parameters<FindCalleesParams>) -> CallToolResult {
937        let branch = p
938            .branch
939            .as_deref()
940            .unwrap_or(&self.default_branch)
941            .to_owned();
942        let depth = p.depth.unwrap_or(1).max(1);
943        let store = match self.store.lock() {
944            Ok(g) => g,
945            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
946        };
947        match store.find_callees(&branch, &p.function_name, depth) {
948            Ok(result) => {
949                let hops: Vec<_> = result
950                    .hops
951                    .iter()
952                    .enumerate()
953                    .map(|(i, nodes)| {
954                        let callees: Vec<_> = nodes
955                            .iter()
956                            .map(|n| {
957                                json!({
958                                    "kind": n.kind.to_string(),
959                                    "name": n.name,
960                                    "qualified_name": n.qualified_name,
961                                    "file": n.file.display().to_string(),
962                                    "start_line": n.span.start_line,
963                                })
964                            })
965                            .collect();
966                        json!({ "hop": i + 1, "callees": callees })
967                    })
968                    .collect();
969                CallToolResult::structured(json!({
970                    "function": p.function_name,
971                    "depth": depth,
972                    "hops": hops,
973                }))
974            }
975            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
976        }
977    }
978
979    /// Find all structs/classes that implement a trait or interface.
980    #[tool(
981        description = "Find all concrete types (structs, classes) that implement or inherit the named \
982        trait or interface. Works for Rust traits, Java/TypeScript interfaces, and Go structural types."
983    )]
984    fn find_implementors(
985        &self,
986        Parameters(p): Parameters<FindImplementorsParams>,
987    ) -> CallToolResult {
988        let branch = p
989            .branch
990            .as_deref()
991            .unwrap_or(&self.default_branch)
992            .to_owned();
993        let store = match self.store.lock() {
994            Ok(g) => g,
995            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
996        };
997        match store.find_implementors(&branch, &p.trait_name) {
998            Ok(nodes) => {
999                let items: Vec<_> = nodes
1000                    .iter()
1001                    .map(|n| {
1002                        json!({
1003                            "kind": n.kind.to_string(),
1004                            "name": n.name,
1005                            "qualified_name": n.qualified_name,
1006                            "file": n.file.display().to_string(),
1007                            "start_line": n.span.start_line,
1008                        })
1009                    })
1010                    .collect();
1011                let (items, truncated) = self.budget_items(items);
1012                CallToolResult::structured(json!({
1013                    "trait": p.trait_name,
1014                    "implementors": items,
1015                    "truncated": truncated,
1016                }))
1017            }
1018            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1019        }
1020    }
1021
1022    /// List the in-repo modules a module depends on.
1023    #[tool(
1024        description = "List the in-repo modules a given module depends on, resolved by following its imports to the defining module of each imported symbol. Useful for understanding internal coupling and architecture. Only intra-repo dependencies appear (external/stdlib imports are not graphed)."
1025    )]
1026    fn module_dependencies(
1027        &self,
1028        Parameters(p): Parameters<ModuleDependenciesParams>,
1029    ) -> CallToolResult {
1030        let branch = p
1031            .branch
1032            .as_deref()
1033            .unwrap_or(&self.default_branch)
1034            .to_owned();
1035        let store = match self.store.lock() {
1036            Ok(g) => g,
1037            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1038        };
1039        match store.module_dependencies(&branch, &p.name) {
1040            Ok(nodes) => {
1041                let items: Vec<_> = nodes
1042                    .iter()
1043                    .map(|n| {
1044                        json!({
1045                            "name": n.name,
1046                            "file": n.file.display().to_string(),
1047                        })
1048                    })
1049                    .collect();
1050                CallToolResult::structured(json!({
1051                    "module": p.name,
1052                    "depends_on": items,
1053                }))
1054            }
1055            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1056        }
1057    }
1058
1059    /// Find functions/methods that use a type in their signature.
1060    #[tool(
1061        description = "Find functions/methods that reference a type as a parameter or return type (follows Uses edges). The type-level analogue of find_callers: answers 'what would break if I change type T's shape'. Returns the using functions/methods."
1062    )]
1063    fn find_type_usages(&self, Parameters(p): Parameters<FindTypeUsagesParams>) -> CallToolResult {
1064        let branch = p
1065            .branch
1066            .as_deref()
1067            .unwrap_or(&self.default_branch)
1068            .to_owned();
1069        let store = match self.store.lock() {
1070            Ok(g) => g,
1071            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1072        };
1073        match store.find_type_usages(&branch, &p.name) {
1074            Ok(nodes) => {
1075                let items: Vec<_> = nodes
1076                    .iter()
1077                    .map(|n| {
1078                        json!({
1079                            "kind": n.kind.to_string(),
1080                            "name": n.name,
1081                            "qualified_name": n.qualified_name,
1082                            "file": n.file.display().to_string(),
1083                            "start_line": n.span.start_line,
1084                        })
1085                    })
1086                    .collect();
1087                let (items, truncated) = self.budget_items(items);
1088                CallToolResult::structured(json!({
1089                    "type": p.name,
1090                    "usages": items,
1091                    "truncated": truncated,
1092                }))
1093            }
1094            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1095        }
1096    }
1097
1098    /// Find the exact call sites (caller + line) of a function.
1099    #[tool(
1100        description = "Find every call site of a function: the calling symbol AND the source line of each call. Where find_callers gives only the calling functions, this pinpoints the exact line each call happens on — useful for reviewing or editing every invocation."
1101    )]
1102    fn get_call_sites(&self, Parameters(p): Parameters<GetCallSitesParams>) -> CallToolResult {
1103        let branch = p
1104            .branch
1105            .as_deref()
1106            .unwrap_or(&self.default_branch)
1107            .to_owned();
1108        let store = match self.store.lock() {
1109            Ok(g) => g,
1110            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1111        };
1112        match store.find_call_sites(&branch, &p.name) {
1113            Ok(sites) => {
1114                let items: Vec<_> = sites
1115                    .iter()
1116                    .map(|s| {
1117                        json!({
1118                            "caller": s.caller.name,
1119                            "caller_kind": s.caller.kind.to_string(),
1120                            "file": s.caller.file.display().to_string(),
1121                            "line": s.line,
1122                            "caller_start_line": s.caller.span.start_line,
1123                        })
1124                    })
1125                    .collect();
1126                let total = items.len();
1127                let (items, truncated) = self.budget_items(items);
1128                CallToolResult::structured(json!({
1129                    "function": p.name,
1130                    "call_sites": items,
1131                    "count": total,
1132                    "returned": items.len(),
1133                    "truncated": truncated,
1134                }))
1135            }
1136            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1137        }
1138    }
1139
1140    /// Find which files/modules import a given symbol.
1141    #[tool(
1142        description = "Find which files/modules import a given symbol (follows Imports edges). Answers 'who depends on X' at the import level — useful before renaming or moving a symbol. Returns the importing module nodes."
1143    )]
1144    fn find_importers(&self, Parameters(p): Parameters<FindImportersParams>) -> CallToolResult {
1145        let branch = p
1146            .branch
1147            .as_deref()
1148            .unwrap_or(&self.default_branch)
1149            .to_owned();
1150        let store = match self.store.lock() {
1151            Ok(g) => g,
1152            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1153        };
1154        match store.find_importers(&branch, &p.name) {
1155            Ok(nodes) => {
1156                let items: Vec<_> = nodes
1157                    .iter()
1158                    .map(|n| {
1159                        json!({
1160                            "kind": n.kind.to_string(),
1161                            "name": n.name,
1162                            "qualified_name": n.qualified_name,
1163                            "file": n.file.display().to_string(),
1164                            "start_line": n.span.start_line,
1165                        })
1166                    })
1167                    .collect();
1168                let (items, truncated) = self.budget_items(items);
1169                CallToolResult::structured(json!({
1170                    "symbol": p.name,
1171                    "importers": items,
1172                    "truncated": truncated,
1173                }))
1174            }
1175            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1176        }
1177    }
1178
1179    /// Map both directions of a type's relationships in one call.
1180    #[tool(
1181        description = "Map a type's full relationship hierarchy in one call: supertypes (the traits/interfaces/classes it implements or extends) AND subtypes (the types that implement or extend it). Where find_implementors gives only the downward direction, this gives both. Works across Rust traits, Java/TypeScript interfaces, and inheritance chains."
1182    )]
1183    fn type_hierarchy(&self, Parameters(p): Parameters<TypeHierarchyParams>) -> CallToolResult {
1184        let branch = p
1185            .branch
1186            .as_deref()
1187            .unwrap_or(&self.default_branch)
1188            .to_owned();
1189        let store = match self.store.lock() {
1190            Ok(g) => g,
1191            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1192        };
1193        match store.type_hierarchy(&branch, &p.name) {
1194            Ok(h) => {
1195                let to_items = |nodes: &[gitcortex_core::graph::Node]| -> serde_json::Value {
1196                    json!(nodes
1197                        .iter()
1198                        .map(|n| json!({
1199                            "kind": n.kind.to_string(),
1200                            "name": n.name,
1201                            "qualified_name": n.qualified_name,
1202                            "file": n.file.display().to_string(),
1203                            "start_line": n.span.start_line,
1204                        }))
1205                        .collect::<Vec<_>>())
1206                };
1207                CallToolResult::structured(json!({
1208                    "type": p.name,
1209                    "supertypes": to_items(&h.supertypes),
1210                    "subtypes": to_items(&h.subtypes),
1211                }))
1212            }
1213            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1214        }
1215    }
1216
1217    /// Find a call path between two symbols in the codebase.
1218    #[tool(
1219        description = "Find a call path from one function to another. Returns the shortest chain of \
1220        calls connecting `from` to `to`. Returns an empty array if no path exists within 6 hops. \
1221        Most useful for debugging 'how can A reach B?' questions."
1222    )]
1223    fn trace_path(&self, Parameters(p): Parameters<TracePathParams>) -> CallToolResult {
1224        let branch = p
1225            .branch
1226            .as_deref()
1227            .unwrap_or(&self.default_branch)
1228            .to_owned();
1229        let store = match self.store.lock() {
1230            Ok(g) => g,
1231            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1232        };
1233        match store.trace_path(&branch, &p.from, &p.to) {
1234            Ok(path) => {
1235                let nodes: Vec<_> = path
1236                    .iter()
1237                    .map(|n| {
1238                        json!({
1239                            "kind": n.kind.to_string(),
1240                            "name": n.name,
1241                            "file": n.file.display().to_string(),
1242                            "start_line": n.span.start_line,
1243                        })
1244                    })
1245                    .collect();
1246                CallToolResult::structured(json!({
1247                    "from": p.from,
1248                    "to": p.to,
1249                    "found": !path.is_empty(),
1250                    "path": nodes,
1251                }))
1252            }
1253            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1254        }
1255    }
1256
1257    /// Find all indexed symbols that overlap a line range in a file.
1258    #[tool(
1259        description = "List all symbols (functions, structs, etc.) in a source file whose span \
1260        overlaps the given line range. Use this to map a stack trace, diff hunk, or grep result \
1261        to the symbols responsible."
1262    )]
1263    fn list_symbols_in_range(
1264        &self,
1265        Parameters(p): Parameters<ListSymbolsInRangeParams>,
1266    ) -> CallToolResult {
1267        let branch = p
1268            .branch
1269            .as_deref()
1270            .unwrap_or(&self.default_branch)
1271            .to_owned();
1272        let store = match self.store.lock() {
1273            Ok(g) => g,
1274            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1275        };
1276        let path = Path::new(&p.file);
1277        match store.list_symbols_in_range(&branch, path, p.start_line, p.end_line) {
1278            Ok(nodes) => {
1279                let items: Vec<_> = nodes
1280                    .iter()
1281                    .map(|n| {
1282                        json!({
1283                            "kind": n.kind.to_string(),
1284                            "name": n.name,
1285                            "qualified_name": n.qualified_name,
1286                            "start_line": n.span.start_line,
1287                            "end_line": n.span.end_line,
1288                            "loc": n.metadata.loc,
1289                        })
1290                    })
1291                    .collect();
1292                let (items, truncated) = self.budget_items(items);
1293                CallToolResult::structured(json!({
1294                    "file": p.file,
1295                    "range": { "start": p.start_line, "end": p.end_line },
1296                    "symbols": items,
1297                    "truncated": truncated,
1298                }))
1299            }
1300            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1301        }
1302    }
1303
1304    /// Find symbols with no callers or type references — potential dead code.
1305    #[tool(
1306        description = "Find symbols that are never called or used as a type anywhere in the indexed \
1307        codebase. Useful for identifying dead code, safe-to-rename candidates, or refactoring targets. \
1308        Pass kind='function' to restrict to functions only."
1309    )]
1310    fn find_unused_symbols(
1311        &self,
1312        Parameters(p): Parameters<FindUnusedSymbolsParams>,
1313    ) -> CallToolResult {
1314        let branch = p
1315            .branch
1316            .as_deref()
1317            .unwrap_or(&self.default_branch)
1318            .to_owned();
1319        let kind = p.kind.as_deref().and_then(|k| match k {
1320            "function" => Some(NodeKind::Function),
1321            "method" => Some(NodeKind::Method),
1322            "struct" => Some(NodeKind::Struct),
1323            "trait" => Some(NodeKind::Trait),
1324            "interface" => Some(NodeKind::Interface),
1325            "enum" => Some(NodeKind::Enum),
1326            "constant" => Some(NodeKind::Constant),
1327            _ => None,
1328        });
1329        let store = match self.store.lock() {
1330            Ok(g) => g,
1331            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1332        };
1333        let limit = p.limit.unwrap_or(30).min(200);
1334        match store.find_unused_symbols(&branch, kind) {
1335            Ok(nodes) => {
1336                // Return a ranked head, not the whole list. An agent acts on the
1337                // first handful; dumping every unused symbol costs more tokens
1338                // than a grep the model would have run instead.
1339                let items: Vec<_> = nodes
1340                    .iter()
1341                    .take(limit)
1342                    .map(|n| {
1343                        json!({
1344                            "kind": n.kind.to_string(),
1345                            "name": n.name,
1346                            "qualified_name": n.qualified_name,
1347                            "file": n.file.display().to_string(),
1348                            "start_line": n.span.start_line,
1349                            "visibility": format!("{:?}", n.metadata.visibility),
1350                        })
1351                    })
1352                    .collect();
1353                let total = nodes.len();
1354                let (items, budget_trunc) = self.budget_items(items);
1355                CallToolResult::structured(json!({
1356                    "branch": branch,
1357                    "unused_symbols": items,
1358                    "count": total,
1359                    "returned": items.len(),
1360                    "truncated": total > items.len() || budget_trunc,
1361                }))
1362            }
1363            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1364        }
1365    }
1366
1367    /// Return a neighbourhood subgraph around a seed symbol.
1368    #[tool(
1369        description = "Return the subgraph centred on a seed symbol — nodes and edges reachable \
1370        within `depth` hops (default 1; raise for wider context). Direction='out' downstream, \
1371        'in' upstream, 'both' (default). Capped at `limit` nodes (default 30) with a `truncated` \
1372        flag — prefer find_callers/find_callees for a targeted answer over a wide neighbourhood dump."
1373    )]
1374    fn get_subgraph(&self, Parameters(p): Parameters<GetSubgraphParams>) -> CallToolResult {
1375        let branch = p
1376            .branch
1377            .as_deref()
1378            .unwrap_or(&self.default_branch)
1379            .to_owned();
1380        let depth = p.depth.unwrap_or(1).clamp(1, 5);
1381        let max_nodes = p.limit.unwrap_or(20).min(200);
1382        let direction = p.direction.as_deref().unwrap_or("both").to_owned();
1383        let store = match self.store.lock() {
1384            Ok(g) => g,
1385            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1386        };
1387        match store.get_subgraph(&branch, &p.seed_name, depth, &direction) {
1388            Ok(sg) => {
1389                // Cap the node set, then keep only edges whose endpoints both
1390                // survive — a full neighbourhood dump on a hub symbol otherwise
1391                // costs more tokens than reading the file it describes.
1392                let kept: Vec<_> = sg.nodes.iter().take(max_nodes).collect();
1393                let kept_ids: std::collections::HashSet<String> =
1394                    kept.iter().map(|n| n.id.as_str()).collect();
1395                // id → name, so edges read as names (the model can't use UUIDs;
1396                // emitting them twice per edge is pure token waste).
1397                let name_of: std::collections::HashMap<String, &str> = kept
1398                    .iter()
1399                    .map(|n| (n.id.as_str(), n.name.as_str()))
1400                    .collect();
1401                let nodes: Vec<_> = kept
1402                    .iter()
1403                    .map(|n| {
1404                        json!({
1405                            "kind": n.kind.to_string(),
1406                            "name": n.name,
1407                            "file": n.file.display().to_string(),
1408                            "start_line": n.span.start_line,
1409                        })
1410                    })
1411                    .collect();
1412                let edges: Vec<_> = sg
1413                    .edges
1414                    .iter()
1415                    .filter(|e| {
1416                        kept_ids.contains(&e.src.as_str()) && kept_ids.contains(&e.dst.as_str())
1417                    })
1418                    .map(|e| {
1419                        json!({
1420                            "from": name_of.get(&e.src.as_str()).copied().unwrap_or(""),
1421                            "to": name_of.get(&e.dst.as_str()).copied().unwrap_or(""),
1422                            "kind": e.kind.to_string(),
1423                            "confidence": e.confidence.to_string(),
1424                        })
1425                    })
1426                    .collect();
1427                // Enforce the response token budget across both lists.
1428                let (nodes, n_trunc) = self.budget_items(nodes);
1429                let (edges, e_trunc) = self.budget_items(edges);
1430                CallToolResult::structured(json!({
1431                    "seed": p.seed_name,
1432                    "depth": depth,
1433                    "direction": direction,
1434                    "node_count": sg.nodes.len(),
1435                    "edge_count": sg.edges.len(),
1436                    "returned_nodes": nodes.len(),
1437                    "returned_edges": edges.len(),
1438                    "truncated": sg.nodes.len() > nodes.len() || n_trunc || e_trunc,
1439                    "nodes": nodes,
1440                    "edges": edges,
1441                }))
1442            }
1443            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1444        }
1445    }
1446
1447    /// Render a wiki-style markdown summary for a symbol.
1448    #[tool(
1449        description = "Markdown wiki for a symbol: signature, doc-comment, top callers/callees. \
1450        Use for deep explanation; use lookup_symbol for a quick definition."
1451    )]
1452    fn wiki_symbol(&self, Parameters(p): Parameters<WikiSymbolParams>) -> CallToolResult {
1453        let branch = p
1454            .branch
1455            .as_deref()
1456            .unwrap_or(&self.default_branch)
1457            .to_owned();
1458        let store = match self.store.lock() {
1459            Ok(g) => g,
1460            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1461        };
1462        match super::wiki::render_symbol(&*store, &branch, &p.name) {
1463            Ok(markdown) => CallToolResult::structured(json!({
1464                "symbol": p.name,
1465                "branch": branch,
1466                "markdown": markdown,
1467            })),
1468            Err(e) => CallToolResult::error(vec![Content::text(format!("wiki failed: {e}"))]),
1469        }
1470    }
1471
1472    /// Search the graph by name + qualified-name with deterministic ranking.
1473    #[tool(
1474        description = "Search the code graph by name or description. Combines token/fuzzy text \
1475        matching (CamelCase-aware, typo-tolerant) with semantic vector similarity so you can \
1476        search without knowing the exact symbol name. Ranks exact > prefix > semantic > \
1477        substring; functions/structs boosted. Default limit=10."
1478    )]
1479    fn search_code(&self, Parameters(p): Parameters<SearchCodeParams>) -> CallToolResult {
1480        let branch = p
1481            .branch
1482            .as_deref()
1483            .unwrap_or(&self.default_branch)
1484            .to_owned();
1485
1486        // ── Text search ───────────────────────────────────────────────────────
1487        let text_hits = {
1488            let store = match self.store.lock() {
1489                Ok(g) => g,
1490                Err(_) => {
1491                    return CallToolResult::error(vec![Content::text("store mutex poisoned")])
1492                }
1493            };
1494            match super::search::search(&*store, &branch, &p.query, p.limit) {
1495                Ok(h) => h,
1496                Err(e) => {
1497                    return CallToolResult::error(vec![Content::text(format!(
1498                        "search failed: {e}"
1499                    ))])
1500                }
1501            }
1502        };
1503
1504        // ── Semantic search (best-effort, non-blocking) ───────────────────────
1505        // try_lock: never block an MCP call waiting for the background indexer.
1506        let sem_hits = if let Ok(sem) = self.semantic.try_lock() {
1507            if let SemanticState::Ready { embedder, index } = &*sem {
1508                embedder.embed_one(&p.query).ok().map(|qvec| {
1509                    let limit = p.limit.unwrap_or(10).min(200);
1510                    index.top_k(&qvec, limit * 2)
1511                })
1512            } else {
1513                None
1514            }
1515        } else {
1516            None
1517        };
1518
1519        // ── Merge: resolve semantic IDs to full nodes, deduplicate ────────────
1520        let mut all_hits = text_hits;
1521        let text_names: std::collections::HashSet<String> =
1522            all_hits.iter().map(|h| h.name.clone()).collect();
1523
1524        if let Some(sem_ids) = sem_hits {
1525            if !sem_ids.is_empty() {
1526                let store = match self.store.lock() {
1527                    Ok(g) => g,
1528                    Err(_) => {
1529                        return CallToolResult::error(vec![Content::text("store mutex poisoned")])
1530                    }
1531                };
1532                if let Ok(nodes) = store.get_nodes_by_ids(&branch, &sem_ids) {
1533                    for n in nodes {
1534                        if !text_names.contains(&n.name) {
1535                            all_hits.push(super::search::SearchHit {
1536                                name: n.name,
1537                                qualified_name: n.qualified_name,
1538                                kind: n.kind.to_string(),
1539                                file: n.file.display().to_string(),
1540                                start_line: n.span.start_line,
1541                                score: 45, // semantic match: between prefix (60) and substring (30)
1542                            });
1543                        }
1544                    }
1545                }
1546            }
1547        }
1548
1549        let limit = p.limit.unwrap_or(10).min(200);
1550        all_hits.sort_by(|a, b| {
1551            b.score
1552                .cmp(&a.score)
1553                .then_with(|| a.name.len().cmp(&b.name.len()))
1554        });
1555        all_hits.truncate(limit);
1556
1557        CallToolResult::structured(json!({
1558            "query": p.query,
1559            "branch": branch,
1560            "count": all_hits.len(),
1561            "semantic_available": matches!(
1562                self.semantic.try_lock().as_deref(),
1563                Ok(SemanticState::Ready { .. })
1564            ),
1565            "hits": all_hits,
1566        }))
1567    }
1568
1569    /// Generate a guided tour through the repo's important symbols.
1570    #[tool(
1571        description = "Generate a guided tour through the codebase. Without a seed, picks the \
1572        highest-centrality public functions/structs to give a new contributor an entry path. \
1573        With a seed, BFS-walks outward from it along call edges. Returns ordered tour steps \
1574        with rationale per step and a rendered markdown plan."
1575    )]
1576    fn start_tour(&self, Parameters(p): Parameters<StartTourParams>) -> CallToolResult {
1577        let branch = p
1578            .branch
1579            .as_deref()
1580            .unwrap_or(&self.default_branch)
1581            .to_owned();
1582        let store = match self.store.lock() {
1583            Ok(g) => g,
1584            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1585        };
1586        match super::tour::generate(&*store, &branch, p.seed.as_deref(), p.limit) {
1587            Ok(tour) => {
1588                let markdown = super::tour::render_markdown(&tour);
1589                CallToolResult::structured(json!({
1590                    "branch": tour.branch,
1591                    "seed": tour.seed,
1592                    "components": tour.components,
1593                    "steps": tour.steps,
1594                    "markdown": markdown,
1595                }))
1596            }
1597            Err(e) => CallToolResult::error(vec![Content::text(format!("tour failed: {e}"))]),
1598        }
1599    }
1600
1601    /// Single-entry dispatch — one schema instead of fifteen.
1602    ///
1603    /// Prefer this tool to keep per-turn schema overhead low. All individual
1604    /// tools remain available for direct use; this is an additive alias.
1605    #[tool(description = "Query the GitCortex code knowledge graph. \
1606        action: lookup_symbol | find_callers | find_callees | find_unused_symbols | \
1607        get_subgraph | search_code | start_tour | wiki_symbol | trace_path | \
1608        list_definitions | symbol_context | list_symbols_in_range | graph_stats | ast_search | \
1609        type_hierarchy | find_importers | find_type_usages | module_dependencies | \
1610        get_call_sites | branch_diff_graph. \
1611        params: JSON object with the same fields as the individual tool (name/function_name/\
1612        seed_name/query/file/branch/depth/limit/direction as applicable). \
1613        Returns identical output to the individual tool.")]
1614    fn gcx(&self, Parameters(p): Parameters<GcxDispatchParams>) -> CallToolResult {
1615        let branch_val = p
1616            .params
1617            .get("branch")
1618            .and_then(|v| v.as_str())
1619            .map(|s| s.to_owned());
1620
1621        // Helper: extract a string field from params.
1622        macro_rules! str_field {
1623            ($key:expr) => {
1624                match p.params.get($key).and_then(|v| v.as_str()) {
1625                    Some(s) => s.to_owned(),
1626                    None => {
1627                        return CallToolResult::error(vec![Content::text(format!(
1628                            "gcx dispatch: params.{} is required for action={}",
1629                            $key, p.action
1630                        ))])
1631                    }
1632                }
1633            };
1634        }
1635
1636        match p.action.as_str() {
1637            "lookup_symbol" => self.lookup_symbol(Parameters(LookupSymbolParams {
1638                name: str_field!("name"),
1639                fuzzy: p.params.get("fuzzy").and_then(|v| v.as_bool()),
1640                branch: branch_val,
1641            })),
1642            "find_callers" => self.find_callers(Parameters(FindCallersParams {
1643                function_name: str_field!("function_name"),
1644                depth: p
1645                    .params
1646                    .get("depth")
1647                    .and_then(|v| v.as_u64())
1648                    .map(|n| n as u8),
1649                branch: branch_val,
1650            })),
1651            "find_callees" => self.find_callees(Parameters(FindCalleesParams {
1652                function_name: str_field!("function_name"),
1653                depth: p
1654                    .params
1655                    .get("depth")
1656                    .and_then(|v| v.as_u64())
1657                    .map(|n| n as u8),
1658                branch: branch_val,
1659            })),
1660            "find_unused_symbols" => {
1661                self.find_unused_symbols(Parameters(FindUnusedSymbolsParams {
1662                    kind: p
1663                        .params
1664                        .get("kind")
1665                        .and_then(|v| v.as_str())
1666                        .map(|s| s.to_owned()),
1667                    limit: p
1668                        .params
1669                        .get("limit")
1670                        .and_then(|v| v.as_u64())
1671                        .map(|n| n as usize),
1672                    branch: branch_val,
1673                }))
1674            }
1675            "get_subgraph" => self.get_subgraph(Parameters(GetSubgraphParams {
1676                seed_name: str_field!("seed_name"),
1677                depth: p
1678                    .params
1679                    .get("depth")
1680                    .and_then(|v| v.as_u64())
1681                    .map(|n| n as u8),
1682                direction: p
1683                    .params
1684                    .get("direction")
1685                    .and_then(|v| v.as_str())
1686                    .map(|s| s.to_owned()),
1687                limit: p
1688                    .params
1689                    .get("limit")
1690                    .and_then(|v| v.as_u64())
1691                    .map(|n| n as usize),
1692                branch: branch_val,
1693            })),
1694            "search_code" => self.search_code(Parameters(SearchCodeParams {
1695                query: str_field!("query"),
1696                limit: p
1697                    .params
1698                    .get("limit")
1699                    .and_then(|v| v.as_u64())
1700                    .map(|n| n as usize),
1701                branch: branch_val,
1702            })),
1703            "start_tour" => self.start_tour(Parameters(StartTourParams {
1704                seed: p
1705                    .params
1706                    .get("seed")
1707                    .and_then(|v| v.as_str())
1708                    .map(|s| s.to_owned()),
1709                limit: p
1710                    .params
1711                    .get("limit")
1712                    .and_then(|v| v.as_u64())
1713                    .map(|n| n as usize),
1714                branch: branch_val,
1715            })),
1716            "wiki_symbol" => self.wiki_symbol(Parameters(WikiSymbolParams {
1717                name: str_field!("name"),
1718                branch: branch_val,
1719            })),
1720            "trace_path" => self.trace_path(Parameters(TracePathParams {
1721                from: p
1722                    .params
1723                    .get("from")
1724                    .or_else(|| p.params.get("src"))
1725                    .and_then(|v| v.as_str())
1726                    .map(|s| s.to_owned())
1727                    .unwrap_or_default(),
1728                to: p
1729                    .params
1730                    .get("to")
1731                    .or_else(|| p.params.get("dst"))
1732                    .and_then(|v| v.as_str())
1733                    .map(|s| s.to_owned())
1734                    .unwrap_or_default(),
1735                branch: branch_val,
1736            })),
1737            "list_definitions" => self.list_definitions(Parameters(ListDefinitionsParams {
1738                file: str_field!("file"),
1739                branch: branch_val,
1740            })),
1741            "symbol_context" => self.symbol_context(Parameters(SymbolContextParams {
1742                name: str_field!("name"),
1743                branch: branch_val,
1744            })),
1745            "graph_stats" => self.graph_stats(Parameters(GraphStatsParams { branch: branch_val })),
1746            "type_hierarchy" => self.type_hierarchy(Parameters(TypeHierarchyParams {
1747                name: str_field!("name"),
1748                branch: branch_val,
1749            })),
1750            "find_importers" => self.find_importers(Parameters(FindImportersParams {
1751                name: str_field!("name"),
1752                branch: branch_val,
1753            })),
1754            "get_call_sites" => self.get_call_sites(Parameters(GetCallSitesParams {
1755                name: str_field!("name"),
1756                branch: branch_val,
1757            })),
1758            "find_type_usages" => self.find_type_usages(Parameters(FindTypeUsagesParams {
1759                name: str_field!("name"),
1760                branch: branch_val,
1761            })),
1762            "module_dependencies" => {
1763                self.module_dependencies(Parameters(ModuleDependenciesParams {
1764                    name: str_field!("name"),
1765                    branch: branch_val,
1766                }))
1767            }
1768            "ast_search" => self.ast_search(Parameters(AstSearchParams {
1769                kind: p
1770                    .params
1771                    .get("kind")
1772                    .and_then(|v| v.as_str())
1773                    .map(|s| s.to_owned()),
1774                is_async: p.params.get("is_async").and_then(|v| v.as_bool()),
1775                visibility: p
1776                    .params
1777                    .get("visibility")
1778                    .and_then(|v| v.as_str())
1779                    .map(|s| s.to_owned()),
1780                min_complexity: p
1781                    .params
1782                    .get("min_complexity")
1783                    .and_then(|v| v.as_u64())
1784                    .map(|n| n as u32),
1785                max_complexity: p
1786                    .params
1787                    .get("max_complexity")
1788                    .and_then(|v| v.as_u64())
1789                    .map(|n| n as u32),
1790                name_contains: p
1791                    .params
1792                    .get("name_contains")
1793                    .and_then(|v| v.as_str())
1794                    .map(|s| s.to_owned()),
1795                annotation: p
1796                    .params
1797                    .get("annotation")
1798                    .and_then(|v| v.as_str())
1799                    .map(|s| s.to_owned()),
1800                limit: p
1801                    .params
1802                    .get("limit")
1803                    .and_then(|v| v.as_u64())
1804                    .map(|n| n as usize),
1805                branch: branch_val,
1806            })),
1807            "list_symbols_in_range" => {
1808                self.list_symbols_in_range(Parameters(ListSymbolsInRangeParams {
1809                    file: str_field!("file"),
1810                    start_line: p
1811                        .params
1812                        .get("start_line")
1813                        .and_then(|v| v.as_u64())
1814                        .unwrap_or(1) as u32,
1815                    end_line: p
1816                        .params
1817                        .get("end_line")
1818                        .and_then(|v| v.as_u64())
1819                        .unwrap_or(u32::MAX as u64) as u32,
1820                    branch: branch_val,
1821                }))
1822            }
1823            other => CallToolResult::error(vec![Content::text(format!(
1824                "gcx dispatch: unknown action '{other}'. Valid: lookup_symbol, find_callers, \
1825                find_callees, find_unused_symbols, get_subgraph, search_code, start_tour, \
1826                wiki_symbol, trace_path, list_definitions, symbol_context, list_symbols_in_range, \
1827                graph_stats, ast_search, type_hierarchy, find_importers, find_type_usages, \
1828                module_dependencies, get_call_sites"
1829            ))]),
1830        }
1831    }
1832}
1833
1834// ── Prompt parameter types ────────────────────────────────────────────────────
1835
1836#[derive(Debug, Deserialize, JsonSchema)]
1837pub struct DetectImpactParams {
1838    /// Comma-separated list of changed file paths (repo-relative).
1839    pub changed_files: String,
1840    /// Branch to query (defaults to "main").
1841    pub branch: Option<String>,
1842}
1843
1844#[derive(Debug, Deserialize, JsonSchema)]
1845pub struct GenerateMapParams {
1846    /// Branch to document (defaults to "main").
1847    pub branch: Option<String>,
1848}
1849
1850// ── Prompt implementations ────────────────────────────────────────────────────
1851
1852#[prompt_router]
1853impl GitCortexServer {
1854    /// Analyse the blast radius of changed files before committing.
1855    /// Walks the call graph from changed symbols to find all downstream callers
1856    /// and produces a risk assessment (LOW / MEDIUM / HIGH / CRITICAL).
1857    #[prompt(
1858        name = "detect_impact",
1859        description = "Pre-commit impact analysis — maps changed files to affected callers and scores risk"
1860    )]
1861    fn detect_impact(&self, Parameters(p): Parameters<DetectImpactParams>) -> GetPromptResult {
1862        let branch = p.branch.as_deref().unwrap_or("main");
1863        let files = p.changed_files.trim().to_owned();
1864
1865        let user_msg = format!(
1866            r#"I am about to commit changes to these files on branch `{branch}`:
1867
1868{files}
1869
1870Please analyse the blast radius of these changes using the GitCortex knowledge graph:
1871
18721. For each changed file call `list_definitions` to identify which symbols were likely touched.
18732. For each key function or struct, call `find_callers` to find direct callers.
18743. Repeat `find_callers` one level deeper for any HIGH-traffic callers.
18754. Summarise your findings as:
1876   - **Changed symbols**: list each modified function/struct with its file and line.
1877   - **Direct callers**: who calls the changed code.
1878   - **Transitive callers**: notable callers two hops away.
1879   - **Risk level**: LOW / MEDIUM / HIGH / CRITICAL with a one-line justification.
1880   - **Recommended actions**: tests to run, reviewers to notify, docs to update.
1881"#
1882        );
1883
1884        GetPromptResult::new(vec![PromptMessage::new_text(
1885            PromptMessageRole::User,
1886            user_msg,
1887        )])
1888        .with_description("Impact analysis of staged changes using the call graph")
1889    }
1890
1891    /// Generate a Mermaid architecture diagram from the knowledge graph.
1892    /// Summarises modules, key structs/traits, and their relationships.
1893    #[prompt(
1894        name = "generate_map",
1895        description = "Architecture documentation — produces a Mermaid diagram of modules, types, and key relationships"
1896    )]
1897    fn generate_map(&self, Parameters(p): Parameters<GenerateMapParams>) -> GetPromptResult {
1898        let branch = p.branch.as_deref().unwrap_or("main");
1899
1900        let user_msg = format!(
1901            r#"Generate an architecture map of this codebase on branch `{branch}` using GitCortex.
1902
1903Steps:
19041. Call `list_definitions` on each major source file to collect modules, structs, traits, and functions.
19052. Call `find_callers` on the top-level entry points to understand key execution flows.
19063. Call `lookup_symbol` on core traits to find all their implementors.
1907
1908Then produce:
1909
1910## Architecture Overview
1911A prose summary (3–5 sentences) of what this codebase does and how it is structured.
1912
1913## Module Map
1914```mermaid
1915graph TD
1916  %% Add nodes for each module/crate and edges for depends-on relationships
1917```
1918
1919## Key Types
1920A table: | Type | Kind | Responsibility | Implemented by |
1921
1922## Core Flows
1923Numbered list of the 2–4 most important execution paths (entry point → key functions → output).
1924
1925## Dependency Notes
1926Any circular dependencies, large fan-outs, or architectural concerns visible in the graph.
1927"#
1928        );
1929
1930        GetPromptResult::new(vec![PromptMessage::new_text(
1931            PromptMessageRole::User,
1932            user_msg,
1933        )])
1934        .with_description(
1935            "Architecture documentation with Mermaid diagram from the knowledge graph",
1936        )
1937    }
1938}
1939
1940// ── Combined ServerHandler (tools + prompts) ──────────────────────────────────
1941
1942#[tool_handler(router = self.active_tool_router())]
1943#[prompt_handler(router = Self::prompt_router())]
1944impl rmcp::ServerHandler for GitCortexServer {
1945    fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
1946        self.active_tool_router().get(name).cloned()
1947    }
1948}
1949
1950// ── Git diff helpers ──────────────────────────────────────────────────────────
1951
1952fn run_git_diff(repo_root: &Path, args: &[&str]) -> Option<String> {
1953    let out = std::process::Command::new("git")
1954        .args(args)
1955        .current_dir(repo_root)
1956        .output()
1957        .ok()?;
1958    if out.status.success() {
1959        String::from_utf8(out.stdout).ok()
1960    } else {
1961        None
1962    }
1963}
1964
1965/// Parse unified diff text into `(repo_relative_file_path, [(start_line, end_line)])`.
1966fn parse_diff_hunks(diff: &str) -> Vec<(String, Vec<(u32, u32)>)> {
1967    let mut result: Vec<(String, Vec<(u32, u32)>)> = Vec::new();
1968    let mut cur_file: Option<String> = None;
1969    let mut cur_hunks: Vec<(u32, u32)> = Vec::new();
1970
1971    for line in diff.lines() {
1972        if let Some(path) = line.strip_prefix("+++ b/") {
1973            if let Some(f) = cur_file.take() {
1974                if !cur_hunks.is_empty() {
1975                    result.push((f, std::mem::take(&mut cur_hunks)));
1976                }
1977            }
1978            cur_file = Some(path.to_owned());
1979        } else if line.starts_with("@@ ") {
1980            if let Some(hunk) = parse_hunk_header(line) {
1981                cur_hunks.push(hunk);
1982            }
1983        }
1984    }
1985    if let Some(f) = cur_file {
1986        if !cur_hunks.is_empty() {
1987            result.push((f, cur_hunks));
1988        }
1989    }
1990    result
1991}
1992
1993/// Extract the new-file line range from a unified diff hunk header.
1994/// `@@ -old_start[,old_count] +new_start[,new_count] @@`
1995fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
1996    let rest = line.strip_prefix("@@ ")?;
1997    let plus_pos = rest.find(" +")?;
1998    let new_part = &rest[plus_pos + 2..];
1999    let end = new_part.find(' ').unwrap_or(new_part.len());
2000    let range = &new_part[..end];
2001    if let Some(comma) = range.find(',') {
2002        let start: u32 = range[..comma].parse().ok()?;
2003        let count: u32 = range[comma + 1..].parse().ok()?;
2004        Some((start, start + count.saturating_sub(1)))
2005    } else {
2006        let start: u32 = range.parse().ok()?;
2007        Some((start, start))
2008    }
2009}