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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17enum SearchAction {
18 Regex,
19 Semantic,
20 Symbol,
21 Reindex,
22 FindRelated,
23}
24
25impl SearchAction {
26 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
129const 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
158fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
160 if let Some(Value::Array(queries)) = args.get("queries") {
163 return handle_batch_queries(queries, args, ctx);
164 }
165 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 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 let anchored = get_bool(args, "anchored").unwrap_or(false);
190 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 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
274 let saved = total_observed.saturating_sub(total_sent);
275 crate::core::savings_ledger::record_tool_event(
279 "ctx_search",
280 total_observed,
281 total_sent,
282 None,
283 None,
284 );
285
286 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
301fn 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
313fn 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
321fn 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 {
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 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
366fn 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
383fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
387 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 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
431fn 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
449fn 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
479fn 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 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 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 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent, None, None);
707
708 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
723fn 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 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
854fn 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 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 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 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 assert_eq!(
943 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
944 SearchAction::Regex
945 );
946 assert_eq!(
948 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
949 SearchAction::Symbol
950 );
951 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 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 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 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}