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