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