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