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