Skip to main content

zeph_tools/
search_code.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
22// ---------------------------------------------------------------------------
23// Language detection
24// ---------------------------------------------------------------------------
25
26use 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    /// Natural-language query for semantic search.
181    #[serde(default)]
182    query: Option<String>,
183    /// Exact or partial symbol name.
184    #[serde(default)]
185    symbol: Option<String>,
186    /// Optional glob restricting files, for example `crates/zeph-tools/**`.
187    #[serde(default)]
188    file_pattern: Option<String>,
189    /// Also return reference locations when `symbol` is provided.
190    #[serde(default)]
191    include_references: bool,
192    /// Cap on returned locations.
193    #[serde(default = "default_max_results")]
194    max_results: usize,
195}
196
197const fn default_max_results() -> usize {
198    10
199}
200
201/// Maximum directory entries a single structural/grep walk may visit before it
202/// is truncated. Bounds `allowed_paths` resolving to a huge tree (e.g. `~`).
203///
204/// Large monorepos can exceed a few thousand entries in normal operation, so this is
205/// generous; `MAX_WALK_DURATION` is the real backstop against the original
206/// pathological-scope hang (e.g. `allowed_paths` resolving to `~`), since wall-clock
207/// time bounds the walk regardless of how many entries a given filesystem can visit
208/// per second.
209const MAX_WALK_ENTRIES: usize = 50_000;
210
211/// Maximum wall-clock time a single structural/grep walk may run before it is
212/// truncated, independent of entry count (guards against slow filesystems).
213const MAX_WALK_DURATION: Duration = Duration::from_secs(2);
214
215/// Safety net shared by the structural and grep-fallback directory walks.
216///
217/// `allowed_paths` is user/config controlled and may resolve to an
218/// unexpectedly broad root (e.g. `~`). Without a bound, a recursive walk over
219/// such a root can run indefinitely. `tick` caps both the number of entries
220/// visited and elapsed wall-clock time; once either limit is hit, the walk
221/// stops and reports partial results as truncated instead of hanging.
222struct 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    /// Records one visited directory entry; returns `true` once the walk
238    /// should stop because the budget is exhausted.
239    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
251/// Bundles the two pieces of walk-wide mutable state threaded through the
252/// structural/grep directory recursion: the [`WalkBudget`] and the symlink-cycle
253/// guard (canonical paths of directories on the *active* recursion path). Grouped
254/// into one struct so the walk functions stay under clippy's argument-count limit.
255struct WalkState<'a> {
256    budget: &'a mut WalkBudget,
257    ancestors: &'a mut Vec<PathBuf>,
258}
259
260/// Canonicalizes `current` and checks it against the active recursion stack in
261/// `ancestors` — the canonical paths of directories on the *active* recursion path
262/// (not every directory ever visited). A symlinked directory is only a cycle when
263/// following it would revisit one of those ancestors; legitimate symlinks that point
264/// elsewhere (a vendored dependency, a symlinked source file) are not cycles.
265///
266/// Returns `true` when `current` would revisit an ancestor — the caller must stop
267/// without recursing further. Otherwise pushes `current`'s canonical path onto
268/// `ancestors` and returns `false`; the caller **must** pop it (`ancestors.pop()`)
269/// once the recursive body for `current` returns.
270///
271/// Shared by [`collect_structural_hits`] and [`collect_grep_hits`] so the two walk
272/// modes cannot silently diverge (#5588).
273fn 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    /// Returns the collected hits alongside whether the walk was cut short by
418    /// [`WalkBudget`].
419    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                &regex,
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(&params).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
492/// Traverse `allowed_paths` collecting structural symbol hits synchronously.
493///
494/// Extracted as a free function so callers can run it inside
495/// `tokio::task::spawn_blocking` without borrowing `self`.
496///
497/// Returns the collected hits alongside whether the walk was cut short by
498/// [`WalkBudget`] (see its docs for why the bound exists).
499fn 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
570/// Assembles the final `search_code` [`ToolOutput`] from merged hits.
571///
572/// `walk_truncated` reports whether any directory walk (structural or grep
573/// fallback) was cut short by [`WalkBudget`] — surfaced both as a note in the
574/// summary text and as a `truncated` field in `raw_response` so callers can
575/// detect incomplete results programmatically.
576fn 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
647/// Walks `current`, guarding against symlink cycles via [`enters_symlink_cycle`], still
648/// bounded by [`WalkBudget`] via `state.budget`.
649fn 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, // broken symlink target
691            }
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
762/// Symlink cycle guard mirrors [`collect_structural_hits`] via [`enters_symlink_cycle`].
763fn collect_grep_hits(
764    root: &Path,
765    current: &Path,
766    matcher: Option<&glob::Pattern>,
767    regex: &regex::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: &regex::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, // broken symlink target
807            }
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    /// Regression test for the hang reported against a broad `allowed_paths`
970    /// scope (e.g. `~`): a directory containing far more entries than
971    /// `MAX_WALK_ENTRIES` must not be walked exhaustively — the search
972    /// returns promptly with a truncation indicator instead of hanging.
973    #[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    /// A directory cycle created via a symlink pointing back to an ancestor
1010    /// must not send the walk into an infinite loop, even though symlinks are
1011    /// now followed in general (#5577).
1012    #[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    /// Unit-level regression test for #5588: `enters_symlink_cycle` is the single
1043    /// shared implementation of the cycle-detection logic previously duplicated
1044    /// between `collect_structural_hits` and `collect_grep_hits`.
1045    #[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        // Re-entering the same (canonicalized) path is a cycle.
1054        assert!(enters_symlink_cycle(&mut ancestors, dir.path()));
1055        // A cycle must not push a duplicate entry.
1056        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    /// Regression test for #5577 (critic finding M1 / tester coverage gap 1): a
1072    /// symlink several directories deep pointing back at a non-root ancestor — not
1073    /// a direct self-loop — must still be caught. This only works because *every*
1074    /// directory entered (not just symlinked ones) is pushed onto the active
1075    /// recursion stack; a design that only tracked symlinked directories would miss
1076    /// this cycle, since `a` here is a plain directory, not a symlink.
1077    #[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    /// Regression test for #5577 (tester coverage gap 2): `collect_grep_hits`'s
1110    /// cycle-detection logic is a hand-duplicated copy of
1111    /// `collect_structural_hits`'s (see [`WalkState`] docs) and had zero direct
1112    /// test coverage of its own loop-protection path — only the structural walk's
1113    /// was exercised by `search_code_does_not_follow_symlink_loop`. Uses `query`
1114    /// (no `symbol`, no semantic backend attached) so the search falls through to
1115    /// `grep_fallback`, exactly like `search_code_uses_grep_fallback`.
1116    #[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    /// Regression test for #5577: a symlinked directory that does *not* create a
1147    /// cycle (e.g. a vendored dependency symlinked into the tree) must still be
1148    /// walked and searched, not blanket-skipped just because it is a symlink.
1149    #[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    /// Regression test for #5577: a symlinked source file (not a directory) must
1188    /// still be searched, not skipped just because it is a symlink.
1189    #[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    /// `search_code` description must explicitly state it is not for user-provided facts
1257    /// so the model does not use it when recalling conversation context (#2475).
1258    #[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}