1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::LazyLock;
8use std::time::{Duration, Instant};
9
10use schemars::JsonSchema;
11use serde::Deserialize;
12use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
13
14use zeph_common::ToolName;
15
16use crate::executor::{
17 ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
18};
19use crate::file::expand_tilde;
20use crate::registry::{InvocationHint, ToolDef};
21
22use zeph_common::treesitter::{
27 GO_SYM_Q, JS_SYM_Q, PYTHON_SYM_Q, RUST_SYM_Q, TS_SYM_Q, compile_query,
28};
29
30struct LangInfo {
31 grammar: tree_sitter::Language,
32 symbol_query: Option<&'static Query>,
33}
34
35fn lang_info_for_path(path: &Path) -> Option<LangInfo> {
36 let ext = path.extension()?.to_str()?;
37 match ext {
38 "rs" => {
39 static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
40 let lang: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into();
41 compile_query(&lang, RUST_SYM_Q, "rust")
42 });
43 Some(LangInfo {
44 grammar: tree_sitter_rust::LANGUAGE.into(),
45 symbol_query: Q.as_ref(),
46 })
47 }
48 "py" | "pyi" => {
49 static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
50 let lang: tree_sitter::Language = tree_sitter_python::LANGUAGE.into();
51 compile_query(&lang, PYTHON_SYM_Q, "python")
52 });
53 Some(LangInfo {
54 grammar: tree_sitter_python::LANGUAGE.into(),
55 symbol_query: Q.as_ref(),
56 })
57 }
58 "js" | "jsx" | "mjs" | "cjs" => {
59 static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
60 let lang: tree_sitter::Language = tree_sitter_javascript::LANGUAGE.into();
61 compile_query(&lang, JS_SYM_Q, "javascript")
62 });
63 Some(LangInfo {
64 grammar: tree_sitter_javascript::LANGUAGE.into(),
65 symbol_query: Q.as_ref(),
66 })
67 }
68 "ts" | "tsx" | "mts" | "cts" => {
69 static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
70 let lang: tree_sitter::Language =
71 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
72 compile_query(&lang, TS_SYM_Q, "typescript")
73 });
74 Some(LangInfo {
75 grammar: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
76 symbol_query: Q.as_ref(),
77 })
78 }
79 "go" => {
80 static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
81 let lang: tree_sitter::Language = tree_sitter_go::LANGUAGE.into();
82 compile_query(&lang, GO_SYM_Q, "go")
83 });
84 Some(LangInfo {
85 grammar: tree_sitter_go::LANGUAGE.into(),
86 symbol_query: Q.as_ref(),
87 })
88 }
89 "sh" | "bash" | "zsh" => Some(LangInfo {
90 grammar: tree_sitter_bash::LANGUAGE.into(),
91 symbol_query: None,
92 }),
93 "toml" => Some(LangInfo {
94 grammar: tree_sitter_toml_ng::LANGUAGE.into(),
95 symbol_query: None,
96 }),
97 "json" | "jsonc" => Some(LangInfo {
98 grammar: tree_sitter_json::LANGUAGE.into(),
99 symbol_query: None,
100 }),
101 "md" | "markdown" => Some(LangInfo {
102 grammar: tree_sitter_md::LANGUAGE.into(),
103 symbol_query: None,
104 }),
105 _ => None,
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum SearchCodeSource {
112 Semantic,
113 Structural,
114 LspSymbol,
115 LspReferences,
116 GrepFallback,
117}
118
119impl SearchCodeSource {
120 fn label(self) -> &'static str {
121 match self {
122 Self::Semantic => "vector search",
123 Self::Structural => "tree-sitter",
124 Self::LspSymbol => "LSP symbol search",
125 Self::LspReferences => "LSP references",
126 Self::GrepFallback => "grep fallback",
127 }
128 }
129
130 #[must_use]
131 pub fn default_score(self) -> f32 {
132 match self {
133 Self::Structural => 0.98,
134 Self::LspSymbol => 0.95,
135 Self::LspReferences => 0.90,
136 Self::Semantic => 0.75,
137 Self::GrepFallback => 0.45,
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
143pub struct SearchCodeHit {
144 pub file_path: String,
145 pub line_start: usize,
146 pub line_end: usize,
147 pub snippet: String,
148 pub source: SearchCodeSource,
149 pub score: f32,
150 pub symbol_name: Option<String>,
151}
152
153pub trait SemanticSearchBackend: Send + Sync {
154 fn search<'a>(
155 &'a self,
156 query: &'a str,
157 file_pattern: Option<&'a str>,
158 max_results: usize,
159 ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
160}
161
162pub trait LspSearchBackend: Send + Sync {
163 fn workspace_symbol<'a>(
164 &'a self,
165 symbol: &'a str,
166 file_pattern: Option<&'a str>,
167 max_results: usize,
168 ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
169
170 fn references<'a>(
171 &'a self,
172 symbol: &'a str,
173 file_pattern: Option<&'a str>,
174 max_results: usize,
175 ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
176}
177
178#[derive(Deserialize, JsonSchema)]
179struct SearchCodeParams {
180 #[serde(default)]
182 query: Option<String>,
183 #[serde(default)]
185 symbol: Option<String>,
186 #[serde(default)]
188 file_pattern: Option<String>,
189 #[serde(default)]
191 include_references: bool,
192 #[serde(default = "default_max_results")]
194 max_results: usize,
195}
196
197const fn default_max_results() -> usize {
198 10
199}
200
201const MAX_WALK_ENTRIES: usize = 50_000;
210
211const MAX_WALK_DURATION: Duration = Duration::from_secs(2);
214
215struct WalkBudget {
223 started: Instant,
224 visited: usize,
225 truncated: bool,
226}
227
228impl WalkBudget {
229 fn new() -> Self {
230 Self {
231 started: Instant::now(),
232 visited: 0,
233 truncated: false,
234 }
235 }
236
237 fn tick(&mut self) -> bool {
240 if self.truncated {
241 return true;
242 }
243 self.visited += 1;
244 if self.visited > MAX_WALK_ENTRIES || self.started.elapsed() > MAX_WALK_DURATION {
245 self.truncated = true;
246 }
247 self.truncated
248 }
249}
250
251struct WalkState<'a> {
256 budget: &'a mut WalkBudget,
257 ancestors: &'a mut Vec<PathBuf>,
258}
259
260fn enters_symlink_cycle(ancestors: &mut Vec<PathBuf>, current: &Path) -> bool {
274 let canonical = current
275 .canonicalize()
276 .unwrap_or_else(|_| current.to_path_buf());
277 if ancestors.contains(&canonical) {
278 return true;
279 }
280 ancestors.push(canonical);
281 false
282}
283
284pub struct SearchCodeExecutor {
285 allowed_paths: Vec<PathBuf>,
286 semantic_backend: Option<std::sync::Arc<dyn SemanticSearchBackend>>,
287 lsp_backend: Option<std::sync::Arc<dyn LspSearchBackend>>,
288}
289
290impl std::fmt::Debug for SearchCodeExecutor {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 f.debug_struct("SearchCodeExecutor")
293 .field("allowed_paths", &self.allowed_paths)
294 .field("has_semantic_backend", &self.semantic_backend.is_some())
295 .field("has_lsp_backend", &self.lsp_backend.is_some())
296 .finish()
297 }
298}
299
300impl SearchCodeExecutor {
301 #[must_use]
302 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
303 let paths = if allowed_paths.is_empty() {
304 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
305 } else {
306 allowed_paths.into_iter().map(expand_tilde).collect()
307 };
308 Self {
309 allowed_paths: paths
310 .into_iter()
311 .map(|p| p.canonicalize().unwrap_or(p))
312 .collect(),
313 semantic_backend: None,
314 lsp_backend: None,
315 }
316 }
317
318 #[must_use]
319 pub fn with_semantic_backend(
320 mut self,
321 backend: std::sync::Arc<dyn SemanticSearchBackend>,
322 ) -> Self {
323 self.semantic_backend = Some(backend);
324 self
325 }
326
327 #[must_use]
328 pub fn with_lsp_backend(mut self, backend: std::sync::Arc<dyn LspSearchBackend>) -> Self {
329 self.lsp_backend = Some(backend);
330 self
331 }
332
333 async fn handle_search_code(
334 &self,
335 params: &SearchCodeParams,
336 ) -> Result<Option<ToolOutput>, ToolError> {
337 let query = params
338 .query
339 .as_deref()
340 .map(str::trim)
341 .filter(|s| !s.is_empty());
342 let symbol = params
343 .symbol
344 .as_deref()
345 .map(str::trim)
346 .filter(|s| !s.is_empty());
347
348 if query.is_none() && symbol.is_none() {
349 return Err(ToolError::InvalidParams {
350 message: "at least one of `query` or `symbol` must be provided".into(),
351 });
352 }
353
354 let max_results = params.max_results.clamp(1, 50);
355 let mut hits = Vec::new();
356 let mut walk_truncated = false;
357
358 if let Some(query) = query
359 && let Some(backend) = &self.semantic_backend
360 {
361 hits.extend(
362 backend
363 .search(query, params.file_pattern.as_deref(), max_results)
364 .await?,
365 );
366 }
367
368 if let Some(symbol) = symbol {
369 let paths = self.allowed_paths.clone();
370 let sym = symbol.to_owned();
371 let pat = params.file_pattern.clone();
372 let (structural_hits, structural_truncated) = tokio::task::spawn_blocking(move || {
373 collect_all_structural_hits(&paths, &sym, pat.as_deref(), max_results)
374 })
375 .await
376 .map_err(|e| ToolError::Execution(e.into()))??;
377 hits.extend(structural_hits);
378 walk_truncated |= structural_truncated;
379
380 if let Some(backend) = &self.lsp_backend {
381 if let Ok(lsp_hits) = backend
382 .workspace_symbol(symbol, params.file_pattern.as_deref(), max_results)
383 .await
384 {
385 hits.extend(lsp_hits);
386 }
387 if params.include_references
388 && let Ok(lsp_refs) = backend
389 .references(symbol, params.file_pattern.as_deref(), max_results)
390 .await
391 {
392 hits.extend(lsp_refs);
393 }
394 }
395 }
396
397 if hits.is_empty() {
398 let fallback_term = symbol.or(query).unwrap_or_default();
399 let (grep_hits, grep_truncated) =
400 self.grep_fallback(fallback_term, params.file_pattern.as_deref(), max_results)?;
401 hits.extend(grep_hits);
402 walk_truncated |= grep_truncated;
403 }
404
405 let merged = dedupe_hits(hits, max_results);
406 let root = self
407 .allowed_paths
408 .first()
409 .map_or(Path::new("."), PathBuf::as_path);
410 Ok(Some(build_search_code_output(
411 &merged,
412 root,
413 walk_truncated,
414 )))
415 }
416
417 fn grep_fallback(
420 &self,
421 pattern: &str,
422 file_pattern: Option<&str>,
423 max_results: usize,
424 ) -> Result<(Vec<SearchCodeHit>, bool), ToolError> {
425 let matcher = file_pattern
426 .map(glob::Pattern::new)
427 .transpose()
428 .map_err(|e| ToolError::InvalidParams {
429 message: format!("invalid file_pattern: {e}"),
430 })?;
431 let escaped = regex::escape(pattern);
432 let regex = regex::RegexBuilder::new(&escaped)
433 .case_insensitive(true)
434 .build()
435 .map_err(|e| ToolError::InvalidParams {
436 message: e.to_string(),
437 })?;
438 let mut hits = Vec::new();
439 let mut budget = WalkBudget::new();
440 for root in &self.allowed_paths {
441 let mut ancestors = Vec::new();
442 let mut state = WalkState {
443 budget: &mut budget,
444 ancestors: &mut ancestors,
445 };
446 collect_grep_hits(
447 root,
448 root,
449 matcher.as_ref(),
450 ®ex,
451 &mut hits,
452 max_results,
453 &mut state,
454 )?;
455 if hits.len() >= max_results || budget.truncated {
456 break;
457 }
458 }
459 Ok((hits, budget.truncated))
460 }
461}
462
463impl ToolExecutor for SearchCodeExecutor {
464 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
465 Ok(None)
466 }
467
468 #[cfg_attr(
469 feature = "profiling",
470 tracing::instrument(name = "tools.search_code.execute", skip_all)
471 )]
472 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
473 if call.tool_id != "search_code" {
474 return Ok(None);
475 }
476 let params: SearchCodeParams = deserialize_params(&call.params)?;
477 self.handle_search_code(¶ms).await
478 }
479
480 fn tool_definitions(&self) -> Vec<ToolDef> {
481 vec![ToolDef {
482 id: "search_code".into(),
483 description: "Search the codebase using semantic, structural, and LSP sources. Use only to search source code files — not for user-provided facts, preferences, or statements made in conversation.\n\nParameters: query (string, optional) - natural language description to find semantically similar code; symbol (string, optional) - exact or partial symbol name for definition search; file_pattern (string, optional) - glob restricting files; include_references (boolean, optional) - also return symbol references when LSP is available; max_results (integer, optional) - cap results 1-50, default 10\nReturns: ranked code locations with file path, line range, snippet, source label, and score\nErrors: InvalidParams when both query and symbol are empty\nExample: {\"query\": \"where is retry backoff calculated\", \"symbol\": \"retry_backoff_ms\", \"include_references\": true}".into(),
484 schema: schemars::schema_for!(SearchCodeParams),
485 invocation: InvocationHint::ToolCall,
486 output_schema: None,
487 server_id: None,
488 }]
489 }
490}
491
492fn collect_all_structural_hits(
500 allowed_paths: &[PathBuf],
501 symbol: &str,
502 file_pattern: Option<&str>,
503 max_results: usize,
504) -> Result<(Vec<SearchCodeHit>, bool), ToolError> {
505 let matcher = file_pattern
506 .map(glob::Pattern::new)
507 .transpose()
508 .map_err(|e| ToolError::InvalidParams {
509 message: format!("invalid file_pattern: {e}"),
510 })?;
511 let mut hits = Vec::new();
512 let symbol_lower = symbol.to_lowercase();
513 let mut budget = WalkBudget::new();
514 for root in allowed_paths {
515 let mut ancestors = Vec::new();
516 let mut state = WalkState {
517 budget: &mut budget,
518 ancestors: &mut ancestors,
519 };
520 collect_structural_hits(
521 root,
522 root,
523 matcher.as_ref(),
524 &symbol_lower,
525 &mut hits,
526 &mut state,
527 )?;
528 if hits.len() >= max_results || budget.truncated {
529 break;
530 }
531 }
532 Ok((hits, budget.truncated))
533}
534
535fn dedupe_hits(mut hits: Vec<SearchCodeHit>, max_results: usize) -> Vec<SearchCodeHit> {
536 let mut merged: HashMap<(String, usize, usize), SearchCodeHit> = HashMap::new();
537 for hit in hits.drain(..) {
538 let key = (hit.file_path.clone(), hit.line_start, hit.line_end);
539 merged
540 .entry(key)
541 .and_modify(|existing| {
542 if hit.score > existing.score {
543 existing.score = hit.score;
544 existing.snippet.clone_from(&hit.snippet);
545 existing.symbol_name = hit.symbol_name.clone().or(existing.symbol_name.clone());
546 }
547 if existing.source != hit.source {
548 existing.source = if existing.score >= hit.score {
549 existing.source
550 } else {
551 hit.source
552 };
553 }
554 })
555 .or_insert(hit);
556 }
557
558 let mut merged = merged.into_values().collect::<Vec<_>>();
559 merged.sort_by(|a, b| {
560 b.score
561 .partial_cmp(&a.score)
562 .unwrap_or(std::cmp::Ordering::Equal)
563 .then_with(|| a.file_path.cmp(&b.file_path))
564 .then_with(|| a.line_start.cmp(&b.line_start))
565 });
566 merged.truncate(max_results);
567 merged
568}
569
570fn build_search_code_output(
577 hits: &[SearchCodeHit],
578 root: &Path,
579 walk_truncated: bool,
580) -> ToolOutput {
581 let mut summary = format_hits(hits, root);
582 if walk_truncated {
583 summary.push_str(
584 "\n\n[search truncated: directory walk exceeded the safety budget; \
585 results may be incomplete — narrow `allowed_paths` or `file_pattern`]",
586 );
587 }
588 let locations = hits
589 .iter()
590 .map(|hit| hit.file_path.clone())
591 .collect::<Vec<_>>();
592 let raw_response = serde_json::json!({
593 "results": hits.iter().map(|hit| {
594 serde_json::json!({
595 "file_path": hit.file_path,
596 "line_start": hit.line_start,
597 "line_end": hit.line_end,
598 "snippet": hit.snippet,
599 "source": hit.source.label(),
600 "score": hit.score,
601 "symbol_name": hit.symbol_name,
602 })
603 }).collect::<Vec<_>>(),
604 "truncated": walk_truncated,
605 });
606
607 ToolOutput {
608 tool_name: ToolName::new("search_code"),
609 summary,
610 blocks_executed: 1,
611 filter_stats: None,
612 diff: None,
613 streamed: false,
614 terminal_id: None,
615 locations: Some(locations),
616 raw_response: Some(raw_response),
617 claim_source: Some(ClaimSource::CodeSearch),
618 }
619}
620
621fn format_hits(hits: &[SearchCodeHit], root: &Path) -> String {
622 if hits.is_empty() {
623 return "No code matches found.".into();
624 }
625
626 hits.iter()
627 .enumerate()
628 .map(|(idx, hit)| {
629 let display_path = Path::new(&hit.file_path)
630 .strip_prefix(root)
631 .map_or_else(|_| hit.file_path.clone(), |p| p.display().to_string());
632 format!(
633 "[{}] {}:{}-{}\n {}\n source: {}\n score: {:.2}",
634 idx + 1,
635 display_path,
636 hit.line_start,
637 hit.line_end,
638 hit.snippet.replace('\n', " "),
639 hit.source.label(),
640 hit.score,
641 )
642 })
643 .collect::<Vec<_>>()
644 .join("\n\n")
645}
646
647fn collect_structural_hits(
650 root: &Path,
651 current: &Path,
652 matcher: Option<&glob::Pattern>,
653 symbol_lower: &str,
654 hits: &mut Vec<SearchCodeHit>,
655 state: &mut WalkState<'_>,
656) -> Result<(), ToolError> {
657 if should_skip_path(current) || state.budget.truncated {
658 return Ok(());
659 }
660
661 if enters_symlink_cycle(state.ancestors, current) {
662 return Ok(());
663 }
664 let result = collect_structural_hits_inner(root, current, matcher, symbol_lower, hits, state);
665 state.ancestors.pop();
666 result
667}
668
669fn collect_structural_hits_inner(
670 root: &Path,
671 current: &Path,
672 matcher: Option<&glob::Pattern>,
673 symbol_lower: &str,
674 hits: &mut Vec<SearchCodeHit>,
675 state: &mut WalkState<'_>,
676) -> Result<(), ToolError> {
677 let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
678 for entry in entries {
679 if state.budget.tick() {
680 return Ok(());
681 }
682 let entry = entry.map_err(ToolError::Execution)?;
683 let path = entry.path();
684 let Ok(meta) = std::fs::symlink_metadata(&path) else {
685 continue;
686 };
687 let is_dir = if meta.file_type().is_symlink() {
688 match std::fs::metadata(&path) {
689 Ok(target_meta) => target_meta.is_dir(),
690 Err(_) => continue, }
692 } else {
693 meta.is_dir()
694 };
695 if is_dir {
696 collect_structural_hits(root, &path, matcher, symbol_lower, hits, state)?;
697 continue;
698 }
699 if !matches_pattern(root, &path, matcher) {
700 continue;
701 }
702 let Some(info) = lang_info_for_path(&path) else {
703 continue;
704 };
705 let grammar = info.grammar;
706 let Some(query) = info.symbol_query.as_ref() else {
707 continue;
708 };
709 let Ok(source) = std::fs::read_to_string(&path) else {
710 continue;
711 };
712 let mut parser = Parser::new();
713 if parser.set_language(&grammar).is_err() {
714 continue;
715 }
716 let Some(tree) = parser.parse(&source, None) else {
717 continue;
718 };
719 let mut cursor = QueryCursor::new();
720 let capture_names = query.capture_names();
721 let def_idx = capture_names.iter().position(|name| *name == "def");
722 let name_idx = capture_names.iter().position(|name| *name == "name");
723 let (Some(def_idx), Some(name_idx)) = (def_idx, name_idx) else {
724 continue;
725 };
726
727 let mut query_matches = cursor.matches(query, tree.root_node(), source.as_bytes());
728 while let Some(match_) = query_matches.next() {
729 let mut def_node = None;
730 let mut name = None;
731 for capture in match_.captures {
732 if capture.index as usize == def_idx {
733 def_node = Some(capture.node);
734 }
735 if capture.index as usize == name_idx {
736 name = Some(source[capture.node.byte_range()].to_string());
737 }
738 }
739 let Some(name) = name else {
740 continue;
741 };
742 if !name.to_lowercase().contains(symbol_lower) {
743 continue;
744 }
745 let Some(def_node) = def_node else {
746 continue;
747 };
748 hits.push(SearchCodeHit {
749 file_path: canonical_string(&path),
750 line_start: def_node.start_position().row + 1,
751 line_end: def_node.end_position().row + 1,
752 snippet: extract_snippet(&source, def_node.start_position().row + 1),
753 source: SearchCodeSource::Structural,
754 score: SearchCodeSource::Structural.default_score(),
755 symbol_name: Some(name),
756 });
757 }
758 }
759 Ok(())
760}
761
762fn collect_grep_hits(
764 root: &Path,
765 current: &Path,
766 matcher: Option<&glob::Pattern>,
767 regex: ®ex::Regex,
768 hits: &mut Vec<SearchCodeHit>,
769 max_results: usize,
770 state: &mut WalkState<'_>,
771) -> Result<(), ToolError> {
772 if hits.len() >= max_results || should_skip_path(current) || state.budget.truncated {
773 return Ok(());
774 }
775
776 if enters_symlink_cycle(state.ancestors, current) {
777 return Ok(());
778 }
779 let result = collect_grep_hits_inner(root, current, matcher, regex, hits, max_results, state);
780 state.ancestors.pop();
781 result
782}
783
784fn collect_grep_hits_inner(
785 root: &Path,
786 current: &Path,
787 matcher: Option<&glob::Pattern>,
788 regex: ®ex::Regex,
789 hits: &mut Vec<SearchCodeHit>,
790 max_results: usize,
791 state: &mut WalkState<'_>,
792) -> Result<(), ToolError> {
793 let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
794 for entry in entries {
795 if state.budget.tick() {
796 return Ok(());
797 }
798 let entry = entry.map_err(ToolError::Execution)?;
799 let path = entry.path();
800 let Ok(meta) = std::fs::symlink_metadata(&path) else {
801 continue;
802 };
803 let is_dir = if meta.file_type().is_symlink() {
804 match std::fs::metadata(&path) {
805 Ok(target_meta) => target_meta.is_dir(),
806 Err(_) => continue, }
808 } else {
809 meta.is_dir()
810 };
811 if is_dir {
812 collect_grep_hits(root, &path, matcher, regex, hits, max_results, state)?;
813 continue;
814 }
815 if !matches_pattern(root, &path, matcher) {
816 continue;
817 }
818 let Ok(source) = std::fs::read_to_string(&path) else {
819 continue;
820 };
821 for (idx, line) in source.lines().enumerate() {
822 if regex.is_match(line) {
823 hits.push(SearchCodeHit {
824 file_path: canonical_string(&path),
825 line_start: idx + 1,
826 line_end: idx + 1,
827 snippet: line.trim().to_string(),
828 source: SearchCodeSource::GrepFallback,
829 score: SearchCodeSource::GrepFallback.default_score(),
830 symbol_name: None,
831 });
832 if hits.len() >= max_results {
833 return Ok(());
834 }
835 }
836 }
837 }
838 Ok(())
839}
840
841fn matches_pattern(root: &Path, path: &Path, matcher: Option<&glob::Pattern>) -> bool {
842 let Some(matcher) = matcher else {
843 return true;
844 };
845 let relative = path.strip_prefix(root).unwrap_or(path);
846 matcher.matches_path(relative)
847}
848
849fn should_skip_path(path: &Path) -> bool {
850 path.file_name()
851 .and_then(|name| name.to_str())
852 .is_some_and(|name| matches!(name, ".git" | "target" | "node_modules" | ".zeph"))
853}
854
855fn canonical_string(path: &Path) -> String {
856 path.canonicalize()
857 .unwrap_or_else(|_| path.to_path_buf())
858 .display()
859 .to_string()
860}
861
862fn extract_snippet(source: &str, line_number: usize) -> String {
863 source
864 .lines()
865 .nth(line_number.saturating_sub(1))
866 .map(str::trim)
867 .unwrap_or_default()
868 .to_string()
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874 use std::assert_matches;
875
876 struct EmptySemantic;
877
878 impl SemanticSearchBackend for EmptySemantic {
879 fn search<'a>(
880 &'a self,
881 _query: &'a str,
882 _file_pattern: Option<&'a str>,
883 _max_results: usize,
884 ) -> Pin<
885 Box<
886 dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a,
887 >,
888 > {
889 Box::pin(async move { Ok(vec![]) })
890 }
891 }
892
893 #[tokio::test]
894 async fn search_code_requires_query_or_symbol() {
895 let dir = tempfile::tempdir().unwrap();
896 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
897 let call = ToolCall {
898 tool_id: "search_code".into(),
899 params: serde_json::Map::new(),
900 caller_id: None,
901 context: None,
902
903 tool_call_id: String::new(),
904 skill_name: None,
905 };
906 let err = exec.execute_tool_call(&call).await.unwrap_err();
907 assert_matches!(err, ToolError::InvalidParams { .. });
908 }
909
910 #[tokio::test]
911 async fn search_code_finds_structural_symbol() {
912 let dir = tempfile::tempdir().unwrap();
913 let file = dir.path().join("lib.rs");
914 std::fs::write(&file, "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
915 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
916 let call = ToolCall {
917 tool_id: "search_code".into(),
918 params: serde_json::json!({ "symbol": "retry_backoff_ms" })
919 .as_object()
920 .unwrap()
921 .clone(),
922 caller_id: None,
923 context: None,
924
925 tool_call_id: String::new(),
926 skill_name: None,
927 };
928 let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
929 assert!(out.summary.contains("retry_backoff_ms"));
930 assert!(out.summary.contains("tree-sitter"));
931 assert_eq!(out.tool_name, "search_code");
932 }
933
934 #[tokio::test]
935 async fn search_code_uses_grep_fallback() {
936 let dir = tempfile::tempdir().unwrap();
937 let file = dir.path().join("mod.rs");
938 std::fs::write(&file, "let retry_backoff_ms = 5;\n").unwrap();
939 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
940 let call = ToolCall {
941 tool_id: "search_code".into(),
942 params: serde_json::json!({ "query": "retry_backoff_ms" })
943 .as_object()
944 .unwrap()
945 .clone(),
946 caller_id: None,
947 context: None,
948
949 tool_call_id: String::new(),
950 skill_name: None,
951 };
952 let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
953 assert!(out.summary.contains("grep fallback"));
954 }
955
956 #[test]
957 fn walk_budget_truncates_after_entry_limit() {
958 let mut budget = WalkBudget::new();
959 for _ in 0..MAX_WALK_ENTRIES {
960 assert!(!budget.tick(), "budget must not trip before the limit");
961 }
962 assert!(
963 budget.tick(),
964 "budget must trip once entries exceed MAX_WALK_ENTRIES"
965 );
966 assert!(budget.truncated);
967 }
968
969 #[tokio::test]
974 async fn search_code_bounds_wide_directory_walk() {
975 let dir = tempfile::tempdir().unwrap();
976 for i in 0..(MAX_WALK_ENTRIES + 200) {
977 std::fs::write(dir.path().join(format!("f{i}.txt")), "").unwrap();
978 }
979 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
980 let call = ToolCall {
981 tool_id: "search_code".into(),
982 params: serde_json::json!({ "symbol": "nonexistent_symbol_xyz" })
983 .as_object()
984 .unwrap()
985 .clone(),
986 caller_id: None,
987 context: None,
988
989 tool_call_id: String::new(),
990 skill_name: None,
991 };
992 let out = tokio::time::timeout(
993 Duration::from_secs(MAX_WALK_DURATION.as_secs() + 10),
994 exec.execute_tool_call(&call),
995 )
996 .await
997 .expect("search_code must return within the walk budget, not hang")
998 .unwrap()
999 .unwrap();
1000 assert!(
1001 out.summary.contains("truncated"),
1002 "expected truncation note in summary, got: {}",
1003 out.summary
1004 );
1005 let raw = out.raw_response.unwrap();
1006 assert_eq!(raw["truncated"], serde_json::json!(true));
1007 }
1008
1009 #[cfg(unix)]
1013 #[tokio::test]
1014 async fn search_code_does_not_follow_symlink_loop() {
1015 let dir = tempfile::tempdir().unwrap();
1016 let file = dir.path().join("lib.rs");
1017 std::fs::write(&file, "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
1018 let loop_link = dir.path().join("self_loop");
1019 std::os::unix::fs::symlink(dir.path(), &loop_link).unwrap();
1020
1021 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1022 let call = ToolCall {
1023 tool_id: "search_code".into(),
1024 params: serde_json::json!({ "symbol": "retry_backoff_ms" })
1025 .as_object()
1026 .unwrap()
1027 .clone(),
1028 caller_id: None,
1029 context: None,
1030
1031 tool_call_id: String::new(),
1032 skill_name: None,
1033 };
1034 let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1035 .await
1036 .expect("search_code must not hang on a symlink loop")
1037 .unwrap()
1038 .unwrap();
1039 assert!(out.summary.contains("retry_backoff_ms"));
1040 }
1041
1042 #[test]
1046 fn enters_symlink_cycle_detects_repeated_ancestor() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let mut ancestors = Vec::new();
1049
1050 assert!(!enters_symlink_cycle(&mut ancestors, dir.path()));
1051 assert_eq!(ancestors.len(), 1);
1052
1053 assert!(enters_symlink_cycle(&mut ancestors, dir.path()));
1055 assert_eq!(ancestors.len(), 1);
1057 }
1058
1059 #[test]
1060 fn enters_symlink_cycle_allows_distinct_paths() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let child = dir.path().join("child");
1063 std::fs::create_dir_all(&child).unwrap();
1064 let mut ancestors = Vec::new();
1065
1066 assert!(!enters_symlink_cycle(&mut ancestors, dir.path()));
1067 assert!(!enters_symlink_cycle(&mut ancestors, &child));
1068 assert_eq!(ancestors.len(), 2);
1069 }
1070
1071 #[cfg(unix)]
1078 #[tokio::test]
1079 async fn search_code_does_not_follow_indirect_symlink_cycle() {
1080 let dir = tempfile::tempdir().unwrap();
1081 let a = dir.path().join("a");
1082 let c = a.join("b").join("c");
1083 std::fs::create_dir_all(&c).unwrap();
1084 std::fs::write(a.join("lib.rs"), "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
1085 let back_to_a = c.join("back_to_a");
1086 std::os::unix::fs::symlink(&a, &back_to_a).unwrap();
1087
1088 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1089 let call = ToolCall {
1090 tool_id: "search_code".into(),
1091 params: serde_json::json!({ "symbol": "retry_backoff_ms" })
1092 .as_object()
1093 .unwrap()
1094 .clone(),
1095 caller_id: None,
1096 context: None,
1097
1098 tool_call_id: String::new(),
1099 skill_name: None,
1100 };
1101 let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1102 .await
1103 .expect("search_code must not hang on an indirect symlink cycle several levels deep")
1104 .unwrap()
1105 .unwrap();
1106 assert!(out.summary.contains("retry_backoff_ms"));
1107 }
1108
1109 #[cfg(unix)]
1117 #[tokio::test]
1118 async fn search_code_grep_fallback_does_not_follow_symlink_loop() {
1119 let dir = tempfile::tempdir().unwrap();
1120 let file = dir.path().join("mod.rs");
1121 std::fs::write(&file, "let retry_backoff_ms = 5;\n").unwrap();
1122 let loop_link = dir.path().join("self_loop");
1123 std::os::unix::fs::symlink(dir.path(), &loop_link).unwrap();
1124
1125 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1126 let call = ToolCall {
1127 tool_id: "search_code".into(),
1128 params: serde_json::json!({ "query": "retry_backoff_ms" })
1129 .as_object()
1130 .unwrap()
1131 .clone(),
1132 caller_id: None,
1133 context: None,
1134
1135 tool_call_id: String::new(),
1136 skill_name: None,
1137 };
1138 let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1139 .await
1140 .expect("grep fallback must not hang on a symlink loop")
1141 .unwrap()
1142 .unwrap();
1143 assert!(out.summary.contains("grep fallback"));
1144 }
1145
1146 #[cfg(unix)]
1150 #[tokio::test]
1151 async fn search_code_follows_non_looping_symlinked_directory() {
1152 let dir = tempfile::tempdir().unwrap();
1153 let vendor = tempfile::tempdir().unwrap();
1154 std::fs::write(
1155 vendor.path().join("vendored.rs"),
1156 "pub fn vendored_symbol_xyz() -> u64 { 0 }\n",
1157 )
1158 .unwrap();
1159 let link = dir.path().join("vendor_link");
1160 std::os::unix::fs::symlink(vendor.path(), &link).unwrap();
1161
1162 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1163 let call = ToolCall {
1164 tool_id: "search_code".into(),
1165 params: serde_json::json!({ "symbol": "vendored_symbol_xyz" })
1166 .as_object()
1167 .unwrap()
1168 .clone(),
1169 caller_id: None,
1170 context: None,
1171
1172 tool_call_id: String::new(),
1173 skill_name: None,
1174 };
1175 let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1176 .await
1177 .expect("search_code must not hang")
1178 .unwrap()
1179 .unwrap();
1180 assert!(
1181 out.summary.contains("vendored_symbol_xyz"),
1182 "expected symlinked directory to be walked, got: {}",
1183 out.summary
1184 );
1185 }
1186
1187 #[cfg(unix)]
1190 #[tokio::test]
1191 async fn search_code_follows_symlinked_file() {
1192 let dir = tempfile::tempdir().unwrap();
1193 let real_file = tempfile::tempdir().unwrap();
1194 let target = real_file.path().join("real.rs");
1195 std::fs::write(&target, "pub fn symlinked_file_symbol_xyz() -> u64 { 0 }\n").unwrap();
1196 let link = dir.path().join("linked.rs");
1197 std::os::unix::fs::symlink(&target, &link).unwrap();
1198
1199 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1200 let call = ToolCall {
1201 tool_id: "search_code".into(),
1202 params: serde_json::json!({ "symbol": "symlinked_file_symbol_xyz" })
1203 .as_object()
1204 .unwrap()
1205 .clone(),
1206 caller_id: None,
1207 context: None,
1208
1209 tool_call_id: String::new(),
1210 skill_name: None,
1211 };
1212 let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1213 .await
1214 .expect("search_code must not hang")
1215 .unwrap()
1216 .unwrap();
1217 assert!(
1218 out.summary.contains("symlinked_file_symbol_xyz"),
1219 "expected symlinked file to be searched, got: {}",
1220 out.summary
1221 );
1222 }
1223
1224 #[test]
1225 fn tool_definitions_include_search_code() {
1226 let exec = SearchCodeExecutor::new(vec![])
1227 .with_semantic_backend(std::sync::Arc::new(EmptySemantic));
1228 let defs = exec.tool_definitions();
1229 assert_eq!(defs.len(), 1);
1230 assert_eq!(defs[0].id.as_ref(), "search_code");
1231 }
1232
1233 #[test]
1234 fn format_hits_strips_root_prefix() {
1235 let root = Path::new("/tmp/myproject");
1236 let hits = vec![SearchCodeHit {
1237 file_path: "/tmp/myproject/crates/foo/src/lib.rs".to_owned(),
1238 line_start: 10,
1239 line_end: 15,
1240 snippet: "pub fn example() {}".to_owned(),
1241 source: SearchCodeSource::GrepFallback,
1242 score: 0.45,
1243 symbol_name: None,
1244 }];
1245 let output = format_hits(&hits, root);
1246 assert!(
1247 output.contains("crates/foo/src/lib.rs"),
1248 "expected relative path in output, got: {output}"
1249 );
1250 assert!(
1251 !output.contains("/tmp/myproject"),
1252 "absolute path must not appear in output, got: {output}"
1253 );
1254 }
1255
1256 #[tokio::test]
1259 async fn search_code_description_excludes_user_facts() {
1260 let dir = tempfile::tempdir().unwrap();
1261 let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1262 let defs = exec.tool_definitions();
1263 let search_code = defs
1264 .iter()
1265 .find(|d| d.id.as_ref() == "search_code")
1266 .unwrap();
1267 assert!(
1268 search_code
1269 .description
1270 .contains("not for user-provided facts"),
1271 "search_code description must contain disambiguation phrase; got: {}",
1272 search_code.description
1273 );
1274 }
1275}