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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum SearchAction {
17 Regex,
18 Semantic,
19 Symbol,
20 Reindex,
21 FindRelated,
22}
23
24impl SearchAction {
25 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
137const 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
166fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
168 if let Some(Value::Array(queries)) = args.get("queries") {
171 return handle_batch_queries(queries, args, ctx);
172 }
173 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 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 let anchored = get_bool(args, "anchored").unwrap_or(false);
198 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 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
282 let saved = total_observed.saturating_sub(total_sent);
283 crate::core::savings_ledger::record_tool_event(
287 "ctx_search",
288 total_observed,
289 total_sent,
290 None,
291 None,
292 );
293
294 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
309fn 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
321fn 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
329fn 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 {
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 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
374fn 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
391fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
395 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 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
439fn 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
457fn 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
487fn 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 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 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent, None, None);
562
563 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
578fn 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 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
709fn 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 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 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 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 assert_eq!(
798 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
799 SearchAction::Regex
800 );
801 assert_eq!(
803 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
804 SearchAction::Symbol
805 );
806 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 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 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 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}