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