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: regex(pattern, default) | semantic(query) | symbol(name|handle) | \
64             reindex | find_related(file_path,line). anchored=true enables ctx_patch refs; \
65             queries batches regex searches. Run 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" },
74                    "query": { "type": "string" },
75                    "name": { "type": "string" },
76                    "handle": { "type": "string" },
77                    "path": { "type": "string" },
78                    "paths": { "type": "array", "items": { "type": "string" } },
79                    "include": { "type": "string", "description": "Glob, e.g. *.rs" },
80                    "exclude": { "type": "string" },
81                    "exclude_pattern": { "type": "string" },
82                    "anchored": { "type": "boolean" },
83                    "max_results": { "type": "integer" },
84                    "top_k": { "type": "integer" },
85                    "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
86                    "file": { "type": "string" },
87                    "kind": { "type": "string" },
88                    "file_path": { "type": "string" },
89                    "line": { "type": "integer" },
90                    "queries": {
91                        "type": "array",
92                        "items": { "type": "object" }
93                    }
94                },
95                "oneOf": [
96                    {
97                        "properties": { "action": { "enum": ["regex"] } },
98                        "anyOf": [{ "required": ["pattern"] }, { "required": ["queries"] }]
99                    },
100                    {
101                        "properties": { "action": { "const": "semantic" } },
102                        "required": ["action", "query"]
103                    },
104                    {
105                        "properties": { "action": { "const": "symbol" } },
106                        "required": ["action"],
107                        "anyOf": [{ "required": ["name"] }, { "required": ["handle"] }]
108                    },
109                    {
110                        "properties": { "action": { "const": "reindex" } },
111                        "required": ["action"]
112                    },
113                    {
114                        "properties": { "action": { "const": "find_related" } },
115                        "required": ["action", "file_path", "line"]
116                    }
117                ]
118            }),
119        )
120    }
121
122    fn handle(
123        &self,
124        args: &Map<String, Value>,
125        ctx: &ToolContext,
126    ) -> Result<ToolOutput, ErrorData> {
127        match SearchAction::resolve(args) {
128            SearchAction::Regex => handle_regex(args, ctx),
129            SearchAction::Semantic => handle_semantic(args, ctx),
130            SearchAction::Symbol => handle_symbol(args, ctx),
131            SearchAction::Reindex => handle_reindex(args, ctx),
132            SearchAction::FindRelated => handle_find_related(args, ctx),
133        }
134    }
135}
136
137/// Known argument keys for ctx_search — used by the lenient fallback to detect
138/// unrecognized keys that weaker models may use instead of `pattern`.
139const KNOWN_KEYS: &[&str] = &[
140    "action",
141    "pattern",
142    "query",
143    "name",
144    "handle",
145    "path",
146    "paths",
147    "include",
148    "exclude",
149    "exclude_pattern",
150    "ext",
151    "anchored",
152    "max_results",
153    "top_k",
154    "mode",
155    "file",
156    "kind",
157    "file_path",
158    "line",
159    "languages",
160    "path_glob",
161    "workspace",
162    "artifacts",
163    "ignore_gitignore",
164];
165
166/// `action=regex` (default) — exact-pattern search over one or more roots.
167fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
168    // #871: batch mode — `queries: [{pattern, include?, exclude?}]` runs multiple
169    // searches in one round-trip with grouped output.
170    if let Some(Value::Array(queries)) = args.get("queries") {
171        return handle_batch_queries(queries, args, ctx);
172    }
173    // Lenient fallback: if `pattern` is missing, accept the first unrecognized
174    // string value as the pattern. Handles weak models that use keys like
175    // "search_term", "text", "regex", etc. instead of the documented "pattern".
176    let pattern = get_str(args, "pattern")
177        .or_else(|| {
178            args.iter()
179                .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
180                .and_then(|(_, v)| v.as_str().map(String::from))
181        })
182        .ok_or_else(|| {
183            ErrorData::invalid_params(
184                "pattern is required. Example: ctx_search(pattern=\"fn main\", path=\"/src\")",
185                None,
186            )
187        })?;
188    let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
189        .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
190    // `include` is the canonical glob filter; `ext` is the deprecated alias
191    // (bare extension → `*.{ext}`). `include` wins when both are supplied.
192    let include =
193        get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
194    let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
195    let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
196    // #1008: opt-in N:hh line anchors on each hit for direct ctx_patch edits.
197    let anchored = get_bool(args, "anchored").unwrap_or(false);
198    // #870: negative filters — `exclude` (path glob, complement of `include`)
199    // and `exclude_pattern` (regex dropping matching result lines, grep -v).
200    let exclude = get_str(args, "exclude");
201    let exclude_pattern = get_str(args, "exclude_pattern");
202
203    if no_gitignore
204        && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
205    {
206        return Ok(ToolOutput::simple(e));
207    }
208
209    let crp = ctx.crp_mode;
210    let respect = !no_gitignore;
211    let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
212
213    if !resolved.is_multi {
214        return search_single(
215            &pattern,
216            &resolved.roots[0],
217            include.as_deref(),
218            max,
219            crp,
220            respect,
221            allow_secret_paths,
222            anchored,
223            exclude.as_deref(),
224            exclude_pattern.as_deref(),
225        );
226    }
227
228    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
229    let per_root_max = (max / resolved.roots.len()).max(5);
230    let mut combined = String::new();
231    let mut total_observed: usize = 0;
232    let mut total_sent: usize = 0;
233
234    for root in &resolved.roots {
235        let search_result = tokio::task::block_in_place(|| {
236            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
237                crate::tools::ctx_search::handle_filtered(
238                    &pattern,
239                    root,
240                    include.as_deref(),
241                    per_root_max,
242                    crp,
243                    respect,
244                    allow_secret_paths,
245                    anchored,
246                    exclude.as_deref(),
247                    exclude_pattern.as_deref(),
248                )
249            }))
250            .ok()
251        });
252
253        let Some(outcome) = search_result else {
254            combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
255            continue;
256        };
257        let result = outcome.text;
258
259        if result.trim().is_empty() {
260            continue;
261        }
262
263        combined.push_str(&format!("── {root} ──\n{result}\n\n"));
264
265        if result.starts_with("ERROR:") {
266            continue;
267        }
268
269        total_observed += outcome.observed_tokens;
270        total_sent += crate::core::tokens::count_tokens(&result);
271    }
272
273    if combined.is_empty() {
274        combined = "No matches found across any root.".to_string();
275    }
276
277    // Dashboard, footer and verified ledger all use *observed* tokens —
278    // the modeled 2.5x native-grep baseline never inflates user-facing
279    // numbers (GL #573). It only feeds the explicitly-estimated stats
280    // series via `tool_lifecycle::record_search`.
281    let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
282    let saved = total_observed.saturating_sub(total_sent);
283    // #685: `actual_tokens` is the *sent* output, not the saving — passing
284    // `saved` here recorded `actual=observed−sent` and `saved=sent` (both
285    // wrong). Align with cli_grep/cli_shell, which pass the output count.
286    crate::core::savings_ledger::record_tool_event(
287        "ctx_search",
288        total_observed,
289        total_sent,
290        None,
291        None,
292    );
293
294    // R30: Search evidence for batch queries.
295    crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
296
297    Ok(ToolOutput {
298        text: final_out,
299        original_tokens: total_observed,
300        saved_tokens: saved,
301        mode: None,
302        path: None,
303        changed: false,
304        shell_outcome: None,
305        content_blocks: None,
306    })
307}
308
309/// Resolve the `path` arg to a jailed path, falling back to the project root —
310/// the same precedence the former standalone semantic-search tool used.
311fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
312    if let Some(p) = ctx.resolved_path("path") {
313        Ok(p.to_string())
314    } else if let Some(err) = ctx.path_error("path") {
315        Err(ErrorData::invalid_params(format!("path: {err}"), None))
316    } else {
317        Ok(ctx.project_root.clone())
318    }
319}
320
321/// Prime the per-call BM25 cache so semantic engines reuse the warmed index
322/// instead of reloading it from disk (perf parity with the former tool).
323fn prime_bm25_cache(ctx: &ToolContext) {
324    if let Some(ref cache) = ctx.bm25_cache {
325        crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
326    }
327}
328
329/// `action=semantic` — meaning-based search, routed to the shared core fn.
330fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
331    let query = get_str(args, "query")
332        .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
333    let path = resolve_path_or_root(ctx)?;
334    let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
335    let mode = get_str(args, "mode");
336    let languages = get_str_array(args, "languages");
337    let path_glob = get_str(args, "path_glob");
338    let workspace = get_bool(args, "workspace").unwrap_or(false);
339    let artifacts = get_bool(args, "artifacts").unwrap_or(false);
340    prime_bm25_cache(ctx);
341
342    let mut result = tokio::task::block_in_place(|| {
343        crate::tools::ctx_semantic_search::handle(
344            &query,
345            &path,
346            top_k,
347            ctx.crp_mode,
348            languages.as_deref(),
349            path_glob.as_deref(),
350            mode.as_deref(),
351            Some(workspace),
352            Some(artifacts),
353        )
354    });
355
356    // Context Kernel: enrich semantic search with cross-store context
357    {
358        let kernel_budget = 100;
359        if let Some(enrichment) =
360            crate::core::context_kernel::bridge::kernel_enrich(&query, &path, kernel_budget)
361            && !enrichment.blocks.is_empty()
362        {
363            result.push_str("\n--- kernel context ---\n");
364            result.push_str(&enrichment.blocks);
365        }
366    }
367
368    // R30: Search evidence for semantic searches.
369    let search_tokens = crate::core::tokens::count_tokens(&result);
370    crate::tools::search_hook::on_search(&query, "semantic", search_tokens, search_tokens);
371    Ok(semantic_output(result))
372}
373
374/// #1108: when `path` or `file` is an absolute path under a different project,
375/// resolve that project's root for the graph lookup. Falls back to the session
376/// project_root when no cross-project path is given.
377fn resolve_symbol_root(args: &Map<String, Value>, session_root: &str) -> String {
378    let candidate = get_str(args, "path")
379        .or_else(|| get_str(args, "file"))
380        .filter(|p| std::path::Path::new(p.as_str()).is_absolute());
381
382    if let Some(abs_path) = candidate
383        && let Some(detected) = crate::core::protocol::detect_project_root(&abs_path)
384        && detected != session_root
385    {
386        return detected;
387    }
388    session_root.to_string()
389}
390
391/// `action=symbol` — one symbol's body. A `handle` (`path#name@Lline`) resolves
392/// deterministically (exact, no fuzzy/disambiguation); otherwise `name` runs the
393/// fuzzy lookup. Both route to the shared `ctx_symbol` core.
394fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
395    // #1108: resolve graph root from `path` when given, instead of always
396    // using the sticky session project_root. This enables cross-repo symbol
397    // lookup in multi-project MCP sessions.
398    let effective_root = resolve_symbol_root(args, &ctx.project_root);
399
400    if let Some(handle) = get_str(args, "handle") {
401        let (result, original) =
402            crate::tools::ctx_symbol::render_by_handle(&handle, &effective_root);
403        let sent = crate::core::tokens::count_tokens(&result);
404        return Ok(ToolOutput {
405            text: result,
406            original_tokens: original,
407            saved_tokens: original.saturating_sub(sent),
408            mode: Some("handle".to_string()),
409            path: None,
410            changed: false,
411            shell_outcome: None,
412            content_blocks: None,
413        });
414    }
415
416    let name = get_str(args, "name").ok_or_else(|| {
417        ErrorData::invalid_params("name or handle is required for action=symbol", None)
418    })?;
419    let file = get_str(args, "file");
420    let kind = get_str(args, "kind");
421
422    let (result, original) =
423        crate::tools::ctx_symbol::handle(&name, file.as_deref(), kind.as_deref(), &effective_root);
424    let sent = crate::core::tokens::count_tokens(&result);
425    // R30: Search evidence for symbol lookups.
426    crate::tools::search_hook::on_search(&name, "symbol", original, sent);
427    Ok(ToolOutput {
428        text: result,
429        original_tokens: original,
430        saved_tokens: original.saturating_sub(sent),
431        mode: kind,
432        path: file,
433        changed: false,
434        shell_outcome: None,
435        content_blocks: None,
436    })
437}
438
439/// `action=reindex` — rebuild the BM25 (or artifacts) index, routed to core.
440fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
441    let path = resolve_path_or_root(ctx)?;
442    let workspace = get_bool(args, "workspace").unwrap_or(false);
443    let artifacts = get_bool(args, "artifacts").unwrap_or(false);
444    prime_bm25_cache(ctx);
445
446    let result = tokio::task::block_in_place(|| {
447        if artifacts {
448            crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
449        } else {
450            crate::tools::ctx_semantic_search::handle_reindex(&path)
451        }
452    });
453
454    Ok(semantic_output(result))
455}
456
457/// `action=find_related` — context neighbors for a source location, via core.
458fn handle_find_related(
459    args: &Map<String, Value>,
460    ctx: &ToolContext,
461) -> Result<ToolOutput, ErrorData> {
462    let path = resolve_path_or_root(ctx)?;
463    let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
464    let fp = get_str(args, "file_path").unwrap_or_default();
465    let line = get_int(args, "line").unwrap_or(1) as usize;
466    if fp.is_empty() {
467        return Err(ErrorData::invalid_params(
468            "find_related requires file_path and line",
469            None,
470        ));
471    }
472    prime_bm25_cache(ctx);
473
474    let result = tokio::task::block_in_place(|| {
475        crate::tools::ctx_semantic_search::handle_find_related(
476            &fp,
477            line,
478            &path,
479            top_k,
480            ctx.crp_mode,
481        )
482    });
483
484    Ok(semantic_output(result))
485}
486
487/// Shared `ToolOutput` shape for the semantic-engine branches (token accounting
488/// is handled inside the core fns, mirroring the former standalone tool).
489fn semantic_output(text: String) -> ToolOutput {
490    ToolOutput {
491        text,
492        original_tokens: 0,
493        saved_tokens: 0,
494        mode: Some("semantic".to_string()),
495        path: None,
496        changed: false,
497        shell_outcome: None,
498        content_blocks: None,
499    }
500}
501
502#[allow(clippy::too_many_arguments)]
503fn search_single(
504    pattern: &str,
505    path: &str,
506    include: Option<&str>,
507    max: usize,
508    crp: crate::tools::CrpMode,
509    respect_gitignore: bool,
510    allow_secret_paths: bool,
511    anchored: bool,
512    exclude: Option<&str>,
513    exclude_pattern: Option<&str>,
514) -> Result<ToolOutput, ErrorData> {
515    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
516
517    let search_result = tokio::task::block_in_place(|| {
518        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
519            crate::tools::ctx_search::handle_filtered(
520                pattern,
521                path,
522                include,
523                max,
524                crp,
525                respect_gitignore,
526                allow_secret_paths,
527                anchored,
528                exclude,
529                exclude_pattern,
530            )
531        }));
532        match result {
533            Ok(r) => Ok(r),
534            Err(_) => Err("search task panicked"),
535        }
536    });
537
538    let outcome = match search_result {
539        Ok(r) => r,
540        Err(e) => {
541            return Err(ErrorData::internal_error(
542                format!("search task failed: {e}"),
543                None,
544            ));
545        }
546    };
547    let result = outcome.text;
548    // Observed tokens only — the modeled native-grep baseline stays out of
549    // dashboard/footer/ledger (GL #573); see the multi-root branch above.
550    let observed = outcome.observed_tokens;
551
552    if result.starts_with("ERROR:") {
553        return Err(ErrorData::invalid_params(result, None));
554    }
555
556    let sent = crate::core::tokens::count_tokens(&result);
557    let saved = observed.saturating_sub(sent);
558    let final_out = crate::core::protocol::append_savings(&result, observed, sent);
559    // #685: pass the *sent* output as `actual_tokens` (not `saved`); see the
560    // multi-root branch above for why the previous arg was a double bug.
561    crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent, None, None);
562
563    // R30: Search evidence + dedup detection via kernel.
564    crate::tools::search_hook::on_search(pattern, "regex", observed, sent);
565
566    Ok(ToolOutput {
567        text: final_out,
568        original_tokens: observed,
569        saved_tokens: saved,
570        mode: None,
571        path: Some(path.to_string()),
572        changed: false,
573        shell_outcome: None,
574        content_blocks: None,
575    })
576}
577
578/// Translate the deprecated `ext` parameter into an `include` glob.
579///
580/// The historical `ext` accepted a bare extension (`rs` or `.rs`) and matched it
581/// exactly; the equivalent glob is `*.{ext}` (the `glob` crate's `*` spans path
582/// separators, so it still matches at any depth, preserving the old behaviour).
583/// A value that already looks like a glob/path (`*`, `{`, `?`, `/`) is passed
584/// through untouched so any power user who put a pattern in `ext` keeps working.
585/// #871: batch multi-query — runs each query independently and groups output.
586fn handle_batch_queries(
587    queries: &[Value],
588    args: &Map<String, Value>,
589    ctx: &ToolContext,
590) -> Result<ToolOutput, ErrorData> {
591    if queries.is_empty() {
592        return Err(ErrorData::invalid_params(
593            "queries array must not be empty",
594            None,
595        ));
596    }
597    if queries.len() > 10 {
598        return Err(ErrorData::invalid_params(
599            "queries array limited to 10 entries",
600            None,
601        ));
602    }
603
604    let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
605        .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
606    let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
607    let anchored = get_bool(args, "anchored").unwrap_or(false);
608    let crp = ctx.crp_mode;
609    let respect = !no_gitignore;
610    let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
611    let root = &resolved.roots[0];
612    let global_max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
613    let per_query_max = (global_max / queries.len()).max(5);
614
615    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
616    let mut combined = String::new();
617    let mut total_observed: usize = 0;
618    let mut total_sent: usize = 0;
619
620    for (idx, q) in queries.iter().enumerate() {
621        let Some(obj) = q.as_object() else {
622            combined.push_str(&format!(
623                "── query {} ──\nERROR: expected object\n\n",
624                idx + 1
625            ));
626            continue;
627        };
628        let Some(pattern) = get_str(obj, "pattern") else {
629            combined.push_str(&format!(
630                "── query {} ──\nERROR: pattern required\n\n",
631                idx + 1
632            ));
633            continue;
634        };
635        let include =
636            get_str(obj, "include").or_else(|| get_str(obj, "ext").map(|e| ext_to_include(&e)));
637        let exclude = get_str(obj, "exclude");
638        let exclude_pattern = get_str(obj, "exclude_pattern");
639
640        let search_result = tokio::task::block_in_place(|| {
641            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
642                crate::tools::ctx_search::handle_filtered(
643                    &pattern,
644                    root,
645                    include.as_deref(),
646                    per_query_max,
647                    crp,
648                    respect,
649                    allow_secret_paths,
650                    anchored,
651                    exclude.as_deref(),
652                    exclude_pattern.as_deref(),
653                )
654            }))
655            .ok()
656        });
657
658        let label = if queries.len() > 1 {
659            format!(
660                "── query {}: '{}' ──\n",
661                idx + 1,
662                truncate_query(&pattern, 40)
663            )
664        } else {
665            String::new()
666        };
667
668        let Some(outcome) = search_result else {
669            combined.push_str(&format!("{label}ERROR: search panicked\n\n"));
670            continue;
671        };
672
673        if !outcome.text.trim().is_empty() {
674            combined.push_str(&format!("{label}{}\n\n", outcome.text));
675            total_observed += outcome.observed_tokens;
676            total_sent += crate::core::tokens::count_tokens(&outcome.text);
677        }
678    }
679
680    if combined.is_empty() {
681        combined = "No matches found for any query.".to_string();
682    }
683
684    let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
685    let saved = total_observed.saturating_sub(total_sent);
686    crate::core::savings_ledger::record_tool_event(
687        "ctx_search",
688        total_observed,
689        total_sent,
690        None,
691        None,
692    );
693
694    // R30: Search evidence for batch queries.
695    crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
696
697    Ok(ToolOutput {
698        text: final_out,
699        original_tokens: total_observed,
700        saved_tokens: saved,
701        mode: None,
702        path: None,
703        changed: false,
704        shell_outcome: None,
705        content_blocks: None,
706    })
707}
708
709/// Truncate a query string for display (used in batch labels).
710fn truncate_query(q: &str, max: usize) -> String {
711    if q.len() <= max {
712        q.to_string()
713    } else {
714        format!("{}...", &q[..q.floor_char_boundary(max)])
715    }
716}
717
718fn ext_to_include(ext: &str) -> String {
719    if ext.contains(['*', '{', '?', '/']) {
720        return ext.to_string();
721    }
722    let bare = ext.strip_prefix('.').unwrap_or(ext);
723    format!("*.{bare}")
724}
725
726#[cfg(test)]
727mod tests {
728    use super::{SearchAction, ext_to_include};
729    use serde_json::{Map, Value, json};
730
731    fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
732        pairs
733            .iter()
734            .cloned()
735            .map(|(k, v)| (k.to_string(), v))
736            .collect()
737    }
738
739    #[test]
740    fn explicit_action_selects_engine() {
741        // #509: an explicit action always wins, including synonyms.
742        assert_eq!(
743            SearchAction::resolve(&args(&[("action", json!("semantic"))])),
744            SearchAction::Semantic
745        );
746        assert_eq!(
747            SearchAction::resolve(&args(&[("action", json!("symbol"))])),
748            SearchAction::Symbol
749        );
750        assert_eq!(
751            SearchAction::resolve(&args(&[("action", json!("grep"))])),
752            SearchAction::Regex
753        );
754        assert_eq!(
755            SearchAction::resolve(&args(&[("action", json!("related"))])),
756            SearchAction::FindRelated
757        );
758        assert_eq!(
759            SearchAction::resolve(&args(&[("action", json!("reindex"))])),
760            SearchAction::Reindex
761        );
762    }
763
764    #[test]
765    fn action_inferred_from_fields_for_backward_compat() {
766        // Pre-#509 call sites set only one of these fields and no action.
767        assert_eq!(
768            SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
769            SearchAction::Regex
770        );
771        assert_eq!(
772            SearchAction::resolve(&args(&[("query", json!("user auth"))])),
773            SearchAction::Semantic
774        );
775        assert_eq!(
776            SearchAction::resolve(&args(&[("name", json!("handle"))])),
777            SearchAction::Symbol
778        );
779        assert_eq!(
780            SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
781            SearchAction::FindRelated
782        );
783    }
784
785    #[test]
786    fn handle_infers_symbol_action() {
787        // A bare `handle` (no action) must route to the symbol engine.
788        assert_eq!(
789            SearchAction::resolve(&args(&[("handle", json!("src/lib.rs#Config::load@L22"))])),
790            SearchAction::Symbol
791        );
792    }
793
794    #[test]
795    fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
796        // A regex caller that also carries a stray query must stay regex.
797        assert_eq!(
798            SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
799            SearchAction::Regex
800        );
801        // Unknown action value → infer from fields (here: symbol).
802        assert_eq!(
803            SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
804            SearchAction::Symbol
805        );
806        // Nothing recognizable → default regex (the empty-call default).
807        assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
808    }
809
810    #[test]
811    fn ext_alias_bare_extension_becomes_glob() {
812        assert_eq!(ext_to_include("rs"), "*.rs");
813        assert_eq!(ext_to_include("ts"), "*.ts");
814    }
815
816    #[test]
817    fn ext_alias_strips_leading_dot() {
818        assert_eq!(ext_to_include(".rs"), "*.rs");
819        assert_eq!(ext_to_include(".tsx"), "*.tsx");
820    }
821
822    #[test]
823    fn ext_alias_passes_through_glob_like_values() {
824        // Already a glob/path → keep verbatim, don't double-wrap.
825        assert_eq!(ext_to_include("*.rs"), "*.rs");
826        assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
827        assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
828    }
829
830    #[test]
831    fn lenient_fallback_uses_unknown_string_key_as_pattern() {
832        use super::{KNOWN_KEYS, get_str};
833
834        // Simulate Gemma sending {"search_term": "fn main"} — an unknown key
835        // with a string value should be picked up by the lenient fallback.
836        let a = args(&[("search_term", json!("fn main"))]);
837        let pattern = get_str(&a, "pattern").or_else(|| {
838            a.iter()
839                .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
840                .and_then(|(_, v)| v.as_str().map(String::from))
841        });
842        assert_eq!(pattern, Some("fn main".to_string()));
843    }
844
845    #[test]
846    fn lenient_fallback_does_not_grab_known_keys() {
847        use super::{KNOWN_KEYS, get_str};
848
849        // If only known keys are present (but pattern is missing), fallback
850        // should NOT pick them up — it returns None.
851        let a = args(&[("path", json!("/src")), ("max_results", json!(10))]);
852        let pattern = get_str(&a, "pattern").or_else(|| {
853            a.iter()
854                .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
855                .and_then(|(_, v)| v.as_str().map(String::from))
856        });
857        assert_eq!(pattern, None);
858    }
859}