Skip to main content

lean_ctx/tools/registered/
ctx_search.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6    McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, get_str_array, get_usize,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxSearchTool;
11
12/// Which search engine a `ctx_search` call routes to (#509). One tool, many
13/// engines — replacing the former `ctx_search`/`ctx_semantic_search`/`ctx_symbol`
14/// trio with a single, less ambiguous entry point.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum SearchAction {
17    Regex,
18    Semantic,
19    Symbol,
20    Reindex,
21    FindRelated,
22}
23
24impl SearchAction {
25    /// An explicit `action` wins; otherwise the engine is inferred from which
26    /// field the caller set, so pre-#509 call sites (`pattern`/`query`/`name`)
27    /// keep working unchanged. Unknown `action` values fall through to inference.
28    fn resolve(args: &Map<String, Value>) -> Self {
29        if let Some(a) = get_str(args, "action") {
30            match a.trim().to_ascii_lowercase().as_str() {
31                "regex" | "grep" | "pattern" => return Self::Regex,
32                "semantic" | "search" => return Self::Semantic,
33                "symbol" => return Self::Symbol,
34                "reindex" => return Self::Reindex,
35                "find_related" | "related" => return Self::FindRelated,
36                _ => {}
37            }
38        }
39        if args.contains_key("handle") {
40            Self::Symbol
41        } else if args.contains_key("pattern") {
42            Self::Regex
43        } else if args.contains_key("name") {
44            Self::Symbol
45        } else if args.contains_key("file_path") && args.contains_key("line") {
46            Self::FindRelated
47        } else if args.contains_key("query") {
48            Self::Semantic
49        } else {
50            Self::Regex
51        }
52    }
53}
54
55impl McpTool for CtxSearchTool {
56    fn name(&self) -> &'static str {
57        "ctx_search"
58    }
59
60    fn tool_def(&self) -> Tool {
61        tool_def(
62            "ctx_search",
63            "Search code; `action` picks the engine. regex (default): `pattern` exact, \
64             include='*.rs', paths=[..] multi-root. semantic: by meaning (BM25+embeddings), \
65             `query`, mode=bm25|dense|hybrid. symbol: one body by `name` (AST), file/kind narrow, \
66             or `handle`=path#name@Lline (exact, stable). reindex / find_related(file_path,line). \
67             anchored=true tags hits path:line:hh for ctx_patch. Run ctx_compose FIRST.",
68            json!({
69                "type": "object",
70                "properties": {
71                    "action": {
72                        "type": "string",
73                        "enum": ["regex", "semantic", "symbol", "reindex", "find_related"]
74                    },
75                    "pattern": { "type": "string", "description": "Regex pattern" },
76                    "query": { "type": "string", "description": "Meaning query" },
77                    "name": { "type": "string", "description": "Symbol name" },
78                    "handle": { "type": "string", "description": "Stable handle path#name@Lline (exact)" },
79                    "path": { "type": "string", "description": "Scope dir/root" },
80                    "paths": {
81                        "type": "array",
82                        "items": { "type": "string" },
83                        "description": "Multi-root regex"
84                    },
85                    "include": { "type": "string", "description": "Glob, e.g. *.rs" },
86                    "anchored": { "type": "boolean", "description": "Tag hits path:line:hh for ctx_patch" },
87                    "max_results": { "type": "integer" },
88                    "top_k": { "type": "integer" },
89                    "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
90                    "file": { "type": "string", "description": "Narrow to file" },
91                    "kind": { "type": "string", "description": "fn|struct|class|trait|enum" },
92                    "file_path": { "type": "string" },
93                    "line": { "type": "integer" }
94                }
95            }),
96        )
97    }
98
99    fn handle(
100        &self,
101        args: &Map<String, Value>,
102        ctx: &ToolContext,
103    ) -> Result<ToolOutput, ErrorData> {
104        match SearchAction::resolve(args) {
105            SearchAction::Regex => handle_regex(args, ctx),
106            SearchAction::Semantic => handle_semantic(args, ctx),
107            SearchAction::Symbol => handle_symbol(args, ctx),
108            SearchAction::Reindex => handle_reindex(args, ctx),
109            SearchAction::FindRelated => handle_find_related(args, ctx),
110        }
111    }
112}
113
114/// `action=regex` (default) — exact-pattern search over one or more roots.
115fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
116    let pattern = get_str(args, "pattern")
117        .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
118    let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
119        .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
120    // `include` is the canonical glob filter; `ext` is the deprecated alias
121    // (bare extension → `*.{ext}`). `include` wins when both are supplied.
122    let include =
123        get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
124    let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
125    let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
126    // #1008: opt-in N:hh line anchors on each hit for direct ctx_patch edits.
127    let anchored = get_bool(args, "anchored").unwrap_or(false);
128
129    if no_gitignore
130        && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
131    {
132        return Ok(ToolOutput::simple(e));
133    }
134
135    let crp = ctx.crp_mode;
136    let respect = !no_gitignore;
137    let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
138
139    if !resolved.is_multi {
140        return search_single(
141            &pattern,
142            &resolved.roots[0],
143            include.as_deref(),
144            max,
145            crp,
146            respect,
147            allow_secret_paths,
148            anchored,
149        );
150    }
151
152    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
153    let per_root_max = (max / resolved.roots.len()).max(5);
154    let mut combined = String::new();
155    let mut total_observed: usize = 0;
156    let mut total_sent: usize = 0;
157
158    for root in &resolved.roots {
159        let search_result = tokio::task::block_in_place(|| {
160            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
161                crate::tools::ctx_search::handle(
162                    &pattern,
163                    root,
164                    include.as_deref(),
165                    per_root_max,
166                    crp,
167                    respect,
168                    allow_secret_paths,
169                    anchored,
170                )
171            }))
172            .ok()
173        });
174
175        let Some(outcome) = search_result else {
176            combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
177            continue;
178        };
179        let result = outcome.text;
180
181        if result.trim().is_empty() {
182            continue;
183        }
184
185        combined.push_str(&format!("── {root} ──\n{result}\n\n"));
186
187        if result.starts_with("ERROR:") {
188            continue;
189        }
190
191        total_observed += outcome.observed_tokens;
192        total_sent += crate::core::tokens::count_tokens(&result);
193    }
194
195    if combined.is_empty() {
196        combined = "No matches found across any root.".to_string();
197    }
198
199    // Dashboard, footer and verified ledger all use *observed* tokens —
200    // the modeled 2.5x native-grep baseline never inflates user-facing
201    // numbers (GL #573). It only feeds the explicitly-estimated stats
202    // series via `tool_lifecycle::record_search`.
203    let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
204    let saved = total_observed.saturating_sub(total_sent);
205    // #685: `actual_tokens` is the *sent* output, not the saving — passing
206    // `saved` here recorded `actual=observed−sent` and `saved=sent` (both
207    // wrong). Align with cli_grep/cli_shell, which pass the output count.
208    crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
209
210    Ok(ToolOutput {
211        text: final_out,
212        original_tokens: total_observed,
213        saved_tokens: saved,
214        mode: None,
215        path: None,
216        changed: false,
217        shell_outcome: None,
218    })
219}
220
221/// Resolve the `path` arg to a jailed path, falling back to the project root —
222/// the same precedence the former standalone semantic-search tool used.
223fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
224    if let Some(p) = ctx.resolved_path("path") {
225        Ok(p.to_string())
226    } else if let Some(err) = ctx.path_error("path") {
227        Err(ErrorData::invalid_params(format!("path: {err}"), None))
228    } else {
229        Ok(ctx.project_root.clone())
230    }
231}
232
233/// Prime the per-call BM25 cache so semantic engines reuse the warmed index
234/// instead of reloading it from disk (perf parity with the former tool).
235fn prime_bm25_cache(ctx: &ToolContext) {
236    if let Some(ref cache) = ctx.bm25_cache {
237        crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
238    }
239}
240
241/// `action=semantic` — meaning-based search, routed to the shared core fn.
242fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
243    let query = get_str(args, "query")
244        .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
245    let path = resolve_path_or_root(ctx)?;
246    let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
247    let mode = get_str(args, "mode");
248    let languages = get_str_array(args, "languages");
249    let path_glob = get_str(args, "path_glob");
250    let workspace = get_bool(args, "workspace").unwrap_or(false);
251    let artifacts = get_bool(args, "artifacts").unwrap_or(false);
252    prime_bm25_cache(ctx);
253
254    let result = tokio::task::block_in_place(|| {
255        crate::tools::ctx_semantic_search::handle(
256            &query,
257            &path,
258            top_k,
259            ctx.crp_mode,
260            languages.as_deref(),
261            path_glob.as_deref(),
262            mode.as_deref(),
263            Some(workspace),
264            Some(artifacts),
265        )
266    });
267    Ok(semantic_output(result))
268}
269
270/// `action=symbol` — one symbol's body. A `handle` (`path#name@Lline`) resolves
271/// deterministically (exact, no fuzzy/disambiguation); otherwise `name` runs the
272/// fuzzy lookup. Both route to the shared `ctx_symbol` core.
273fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
274    if let Some(handle) = get_str(args, "handle") {
275        let (result, original) =
276            crate::tools::ctx_symbol::render_by_handle(&handle, &ctx.project_root);
277        let sent = crate::core::tokens::count_tokens(&result);
278        return Ok(ToolOutput {
279            text: result,
280            original_tokens: original,
281            saved_tokens: original.saturating_sub(sent),
282            mode: Some("handle".to_string()),
283            path: None,
284            changed: false,
285            shell_outcome: None,
286        });
287    }
288
289    let name = get_str(args, "name").ok_or_else(|| {
290        ErrorData::invalid_params("name or handle is required for action=symbol", None)
291    })?;
292    let file = get_str(args, "file");
293    let kind = get_str(args, "kind");
294
295    let (result, original) = crate::tools::ctx_symbol::handle(
296        &name,
297        file.as_deref(),
298        kind.as_deref(),
299        &ctx.project_root,
300    );
301    let sent = crate::core::tokens::count_tokens(&result);
302    Ok(ToolOutput {
303        text: result,
304        original_tokens: original,
305        saved_tokens: original.saturating_sub(sent),
306        mode: kind,
307        path: file,
308        changed: false,
309        shell_outcome: None,
310    })
311}
312
313/// `action=reindex` — rebuild the BM25 (or artifacts) index, routed to core.
314fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
315    let path = resolve_path_or_root(ctx)?;
316    let workspace = get_bool(args, "workspace").unwrap_or(false);
317    let artifacts = get_bool(args, "artifacts").unwrap_or(false);
318    prime_bm25_cache(ctx);
319
320    let result = tokio::task::block_in_place(|| {
321        if artifacts {
322            crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
323        } else {
324            crate::tools::ctx_semantic_search::handle_reindex(&path)
325        }
326    });
327    Ok(semantic_output(result))
328}
329
330/// `action=find_related` — context neighbors for a source location, via core.
331fn handle_find_related(
332    args: &Map<String, Value>,
333    ctx: &ToolContext,
334) -> Result<ToolOutput, ErrorData> {
335    let path = resolve_path_or_root(ctx)?;
336    let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
337    let fp = get_str(args, "file_path").unwrap_or_default();
338    let line = get_int(args, "line").unwrap_or(1) as usize;
339    if fp.is_empty() {
340        return Err(ErrorData::invalid_params(
341            "find_related requires file_path and line",
342            None,
343        ));
344    }
345    prime_bm25_cache(ctx);
346
347    let result = tokio::task::block_in_place(|| {
348        crate::tools::ctx_semantic_search::handle_find_related(
349            &fp,
350            line,
351            &path,
352            top_k,
353            ctx.crp_mode,
354        )
355    });
356    Ok(semantic_output(result))
357}
358
359/// Shared `ToolOutput` shape for the semantic-engine branches (token accounting
360/// is handled inside the core fns, mirroring the former standalone tool).
361fn semantic_output(text: String) -> ToolOutput {
362    ToolOutput {
363        text,
364        original_tokens: 0,
365        saved_tokens: 0,
366        mode: Some("semantic".to_string()),
367        path: None,
368        changed: false,
369        shell_outcome: None,
370    }
371}
372
373#[allow(clippy::too_many_arguments)]
374fn search_single(
375    pattern: &str,
376    path: &str,
377    include: Option<&str>,
378    max: usize,
379    crp: crate::tools::CrpMode,
380    respect_gitignore: bool,
381    allow_secret_paths: bool,
382    anchored: bool,
383) -> Result<ToolOutput, ErrorData> {
384    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
385
386    let search_result = tokio::task::block_in_place(|| {
387        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
388            crate::tools::ctx_search::handle(
389                pattern,
390                path,
391                include,
392                max,
393                crp,
394                respect_gitignore,
395                allow_secret_paths,
396                anchored,
397            )
398        }));
399        match result {
400            Ok(r) => Ok(r),
401            Err(_) => Err("search task panicked"),
402        }
403    });
404
405    let outcome = match search_result {
406        Ok(r) => r,
407        Err(e) => {
408            return Err(ErrorData::internal_error(
409                format!("search task failed: {e}"),
410                None,
411            ));
412        }
413    };
414    let result = outcome.text;
415    // Observed tokens only — the modeled native-grep baseline stays out of
416    // dashboard/footer/ledger (GL #573); see the multi-root branch above.
417    let observed = outcome.observed_tokens;
418
419    if result.starts_with("ERROR:") {
420        return Err(ErrorData::invalid_params(result, None));
421    }
422
423    let sent = crate::core::tokens::count_tokens(&result);
424    let saved = observed.saturating_sub(sent);
425    let final_out = crate::core::protocol::append_savings(&result, observed, sent);
426    // #685: pass the *sent* output as `actual_tokens` (not `saved`); see the
427    // multi-root branch above for why the previous arg was a double bug.
428    crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
429
430    Ok(ToolOutput {
431        text: final_out,
432        original_tokens: observed,
433        saved_tokens: saved,
434        mode: None,
435        path: Some(path.to_string()),
436        changed: false,
437        shell_outcome: None,
438    })
439}
440
441/// Translate the deprecated `ext` parameter into an `include` glob.
442///
443/// The historical `ext` accepted a bare extension (`rs` or `.rs`) and matched it
444/// exactly; the equivalent glob is `*.{ext}` (the `glob` crate's `*` spans path
445/// separators, so it still matches at any depth, preserving the old behaviour).
446/// A value that already looks like a glob/path (`*`, `{`, `?`, `/`) is passed
447/// through untouched so any power user who put a pattern in `ext` keeps working.
448fn ext_to_include(ext: &str) -> String {
449    if ext.contains(['*', '{', '?', '/']) {
450        return ext.to_string();
451    }
452    let bare = ext.strip_prefix('.').unwrap_or(ext);
453    format!("*.{bare}")
454}
455
456#[cfg(test)]
457mod tests {
458    use super::{SearchAction, ext_to_include};
459    use serde_json::{Map, Value, json};
460
461    fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
462        pairs
463            .iter()
464            .cloned()
465            .map(|(k, v)| (k.to_string(), v))
466            .collect()
467    }
468
469    #[test]
470    fn explicit_action_selects_engine() {
471        // #509: an explicit action always wins, including synonyms.
472        assert_eq!(
473            SearchAction::resolve(&args(&[("action", json!("semantic"))])),
474            SearchAction::Semantic
475        );
476        assert_eq!(
477            SearchAction::resolve(&args(&[("action", json!("symbol"))])),
478            SearchAction::Symbol
479        );
480        assert_eq!(
481            SearchAction::resolve(&args(&[("action", json!("grep"))])),
482            SearchAction::Regex
483        );
484        assert_eq!(
485            SearchAction::resolve(&args(&[("action", json!("related"))])),
486            SearchAction::FindRelated
487        );
488        assert_eq!(
489            SearchAction::resolve(&args(&[("action", json!("reindex"))])),
490            SearchAction::Reindex
491        );
492    }
493
494    #[test]
495    fn action_inferred_from_fields_for_backward_compat() {
496        // Pre-#509 call sites set only one of these fields and no action.
497        assert_eq!(
498            SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
499            SearchAction::Regex
500        );
501        assert_eq!(
502            SearchAction::resolve(&args(&[("query", json!("user auth"))])),
503            SearchAction::Semantic
504        );
505        assert_eq!(
506            SearchAction::resolve(&args(&[("name", json!("handle"))])),
507            SearchAction::Symbol
508        );
509        assert_eq!(
510            SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
511            SearchAction::FindRelated
512        );
513    }
514
515    #[test]
516    fn handle_infers_symbol_action() {
517        // A bare `handle` (no action) must route to the symbol engine.
518        assert_eq!(
519            SearchAction::resolve(&args(&[("handle", json!("src/lib.rs#Config::load@L22"))])),
520            SearchAction::Symbol
521        );
522    }
523
524    #[test]
525    fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
526        // A regex caller that also carries a stray query must stay regex.
527        assert_eq!(
528            SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
529            SearchAction::Regex
530        );
531        // Unknown action value → infer from fields (here: symbol).
532        assert_eq!(
533            SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
534            SearchAction::Symbol
535        );
536        // Nothing recognizable → default regex (the empty-call default).
537        assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
538    }
539
540    #[test]
541    fn ext_alias_bare_extension_becomes_glob() {
542        assert_eq!(ext_to_include("rs"), "*.rs");
543        assert_eq!(ext_to_include("ts"), "*.ts");
544    }
545
546    #[test]
547    fn ext_alias_strips_leading_dot() {
548        assert_eq!(ext_to_include(".rs"), "*.rs");
549        assert_eq!(ext_to_include(".tsx"), "*.tsx");
550    }
551
552    #[test]
553    fn ext_alias_passes_through_glob_like_values() {
554        // Already a glob/path → keep verbatim, don't double-wrap.
555        assert_eq!(ext_to_include("*.rs"), "*.rs");
556        assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
557        assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
558    }
559}