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;
8
9use schemars::JsonSchema;
10use serde::Deserialize;
11use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
12
13use zeph_common::ToolName;
14
15use crate::executor::{
16    ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
17};
18use crate::registry::{InvocationHint, ToolDef};
19
20// ---------------------------------------------------------------------------
21// Language detection
22// ---------------------------------------------------------------------------
23
24use zeph_common::treesitter::{
25    GO_SYM_Q, JS_SYM_Q, PYTHON_SYM_Q, RUST_SYM_Q, TS_SYM_Q, compile_query,
26};
27
28struct LangInfo {
29    grammar: tree_sitter::Language,
30    symbol_query: Option<&'static Query>,
31}
32
33fn lang_info_for_path(path: &Path) -> Option<LangInfo> {
34    let ext = path.extension()?.to_str()?;
35    match ext {
36        "rs" => {
37            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
38                let lang: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into();
39                compile_query(&lang, RUST_SYM_Q, "rust")
40            });
41            Some(LangInfo {
42                grammar: tree_sitter_rust::LANGUAGE.into(),
43                symbol_query: Q.as_ref(),
44            })
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            Some(LangInfo {
52                grammar: tree_sitter_python::LANGUAGE.into(),
53                symbol_query: Q.as_ref(),
54            })
55        }
56        "js" | "jsx" | "mjs" | "cjs" => {
57            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
58                let lang: tree_sitter::Language = tree_sitter_javascript::LANGUAGE.into();
59                compile_query(&lang, JS_SYM_Q, "javascript")
60            });
61            Some(LangInfo {
62                grammar: tree_sitter_javascript::LANGUAGE.into(),
63                symbol_query: Q.as_ref(),
64            })
65        }
66        "ts" | "tsx" | "mts" | "cts" => {
67            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
68                let lang: tree_sitter::Language =
69                    tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
70                compile_query(&lang, TS_SYM_Q, "typescript")
71            });
72            Some(LangInfo {
73                grammar: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
74                symbol_query: Q.as_ref(),
75            })
76        }
77        "go" => {
78            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
79                let lang: tree_sitter::Language = tree_sitter_go::LANGUAGE.into();
80                compile_query(&lang, GO_SYM_Q, "go")
81            });
82            Some(LangInfo {
83                grammar: tree_sitter_go::LANGUAGE.into(),
84                symbol_query: Q.as_ref(),
85            })
86        }
87        "sh" | "bash" | "zsh" => Some(LangInfo {
88            grammar: tree_sitter_bash::LANGUAGE.into(),
89            symbol_query: None,
90        }),
91        "toml" => Some(LangInfo {
92            grammar: tree_sitter_toml_ng::LANGUAGE.into(),
93            symbol_query: None,
94        }),
95        "json" | "jsonc" => Some(LangInfo {
96            grammar: tree_sitter_json::LANGUAGE.into(),
97            symbol_query: None,
98        }),
99        "md" | "markdown" => Some(LangInfo {
100            grammar: tree_sitter_md::LANGUAGE.into(),
101            symbol_query: None,
102        }),
103        _ => None,
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum SearchCodeSource {
109    Semantic,
110    Structural,
111    LspSymbol,
112    LspReferences,
113    GrepFallback,
114}
115
116impl SearchCodeSource {
117    fn label(self) -> &'static str {
118        match self {
119            Self::Semantic => "vector search",
120            Self::Structural => "tree-sitter",
121            Self::LspSymbol => "LSP symbol search",
122            Self::LspReferences => "LSP references",
123            Self::GrepFallback => "grep fallback",
124        }
125    }
126
127    #[must_use]
128    pub fn default_score(self) -> f32 {
129        match self {
130            Self::Structural => 0.98,
131            Self::LspSymbol => 0.95,
132            Self::LspReferences => 0.90,
133            Self::Semantic => 0.75,
134            Self::GrepFallback => 0.45,
135        }
136    }
137}
138
139#[derive(Debug, Clone)]
140pub struct SearchCodeHit {
141    pub file_path: String,
142    pub line_start: usize,
143    pub line_end: usize,
144    pub snippet: String,
145    pub source: SearchCodeSource,
146    pub score: f32,
147    pub symbol_name: Option<String>,
148}
149
150pub trait SemanticSearchBackend: Send + Sync {
151    fn search<'a>(
152        &'a self,
153        query: &'a str,
154        file_pattern: Option<&'a str>,
155        max_results: usize,
156    ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
157}
158
159pub trait LspSearchBackend: Send + Sync {
160    fn workspace_symbol<'a>(
161        &'a self,
162        symbol: &'a str,
163        file_pattern: Option<&'a str>,
164        max_results: usize,
165    ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
166
167    fn references<'a>(
168        &'a self,
169        symbol: &'a str,
170        file_pattern: Option<&'a str>,
171        max_results: usize,
172    ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
173}
174
175#[derive(Deserialize, JsonSchema)]
176struct SearchCodeParams {
177    /// Natural-language query for semantic search.
178    #[serde(default)]
179    query: Option<String>,
180    /// Exact or partial symbol name.
181    #[serde(default)]
182    symbol: Option<String>,
183    /// Optional glob restricting files, for example `crates/zeph-tools/**`.
184    #[serde(default)]
185    file_pattern: Option<String>,
186    /// Also return reference locations when `symbol` is provided.
187    #[serde(default)]
188    include_references: bool,
189    /// Cap on returned locations.
190    #[serde(default = "default_max_results")]
191    max_results: usize,
192}
193
194const fn default_max_results() -> usize {
195    10
196}
197
198pub struct SearchCodeExecutor {
199    allowed_paths: Vec<PathBuf>,
200    semantic_backend: Option<std::sync::Arc<dyn SemanticSearchBackend>>,
201    lsp_backend: Option<std::sync::Arc<dyn LspSearchBackend>>,
202}
203
204impl std::fmt::Debug for SearchCodeExecutor {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        f.debug_struct("SearchCodeExecutor")
207            .field("allowed_paths", &self.allowed_paths)
208            .field("has_semantic_backend", &self.semantic_backend.is_some())
209            .field("has_lsp_backend", &self.lsp_backend.is_some())
210            .finish()
211    }
212}
213
214impl SearchCodeExecutor {
215    #[must_use]
216    pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
217        let paths = if allowed_paths.is_empty() {
218            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
219        } else {
220            allowed_paths
221        };
222        Self {
223            allowed_paths: paths
224                .into_iter()
225                .map(|p| p.canonicalize().unwrap_or(p))
226                .collect(),
227            semantic_backend: None,
228            lsp_backend: None,
229        }
230    }
231
232    #[must_use]
233    pub fn with_semantic_backend(
234        mut self,
235        backend: std::sync::Arc<dyn SemanticSearchBackend>,
236    ) -> Self {
237        self.semantic_backend = Some(backend);
238        self
239    }
240
241    #[must_use]
242    pub fn with_lsp_backend(mut self, backend: std::sync::Arc<dyn LspSearchBackend>) -> Self {
243        self.lsp_backend = Some(backend);
244        self
245    }
246
247    async fn handle_search_code(
248        &self,
249        params: &SearchCodeParams,
250    ) -> Result<Option<ToolOutput>, ToolError> {
251        let query = params
252            .query
253            .as_deref()
254            .map(str::trim)
255            .filter(|s| !s.is_empty());
256        let symbol = params
257            .symbol
258            .as_deref()
259            .map(str::trim)
260            .filter(|s| !s.is_empty());
261
262        if query.is_none() && symbol.is_none() {
263            return Err(ToolError::InvalidParams {
264                message: "at least one of `query` or `symbol` must be provided".into(),
265            });
266        }
267
268        let max_results = params.max_results.clamp(1, 50);
269        let mut hits = Vec::new();
270
271        if let Some(query) = query
272            && let Some(backend) = &self.semantic_backend
273        {
274            hits.extend(
275                backend
276                    .search(query, params.file_pattern.as_deref(), max_results)
277                    .await?,
278            );
279        }
280
281        if let Some(symbol) = symbol {
282            let paths = self.allowed_paths.clone();
283            let sym = symbol.to_owned();
284            let pat = params.file_pattern.clone();
285            let structural_hits = tokio::task::spawn_blocking(move || {
286                collect_all_structural_hits(&paths, &sym, pat.as_deref(), max_results)
287            })
288            .await
289            .map_err(|e| ToolError::Execution(e.into()))??;
290            hits.extend(structural_hits);
291
292            if let Some(backend) = &self.lsp_backend {
293                if let Ok(lsp_hits) = backend
294                    .workspace_symbol(symbol, params.file_pattern.as_deref(), max_results)
295                    .await
296                {
297                    hits.extend(lsp_hits);
298                }
299                if params.include_references
300                    && let Ok(lsp_refs) = backend
301                        .references(symbol, params.file_pattern.as_deref(), max_results)
302                        .await
303                {
304                    hits.extend(lsp_refs);
305                }
306            }
307        }
308
309        if hits.is_empty() {
310            let fallback_term = symbol.or(query).unwrap_or_default();
311            hits.extend(self.grep_fallback(
312                fallback_term,
313                params.file_pattern.as_deref(),
314                max_results,
315            )?);
316        }
317
318        let merged = dedupe_hits(hits, max_results);
319        let root = self
320            .allowed_paths
321            .first()
322            .map_or(Path::new("."), PathBuf::as_path);
323        let summary = format_hits(&merged, root);
324        let locations = merged
325            .iter()
326            .map(|hit| hit.file_path.clone())
327            .collect::<Vec<_>>();
328        let raw_response = serde_json::json!({
329            "results": merged.iter().map(|hit| {
330                serde_json::json!({
331                    "file_path": hit.file_path,
332                    "line_start": hit.line_start,
333                    "line_end": hit.line_end,
334                    "snippet": hit.snippet,
335                    "source": hit.source.label(),
336                    "score": hit.score,
337                    "symbol_name": hit.symbol_name,
338                })
339            }).collect::<Vec<_>>()
340        });
341
342        Ok(Some(ToolOutput {
343            tool_name: ToolName::new("search_code"),
344            summary,
345            blocks_executed: 1,
346            filter_stats: None,
347            diff: None,
348            streamed: false,
349            terminal_id: None,
350            locations: Some(locations),
351            raw_response: Some(raw_response),
352            claim_source: Some(ClaimSource::CodeSearch),
353        }))
354    }
355
356    fn grep_fallback(
357        &self,
358        pattern: &str,
359        file_pattern: Option<&str>,
360        max_results: usize,
361    ) -> Result<Vec<SearchCodeHit>, ToolError> {
362        let matcher = file_pattern
363            .map(glob::Pattern::new)
364            .transpose()
365            .map_err(|e| ToolError::InvalidParams {
366                message: format!("invalid file_pattern: {e}"),
367            })?;
368        let escaped = regex::escape(pattern);
369        let regex = regex::RegexBuilder::new(&escaped)
370            .case_insensitive(true)
371            .build()
372            .map_err(|e| ToolError::InvalidParams {
373                message: e.to_string(),
374            })?;
375        let mut hits = Vec::new();
376        for root in &self.allowed_paths {
377            collect_grep_hits(root, root, matcher.as_ref(), &regex, &mut hits, max_results)?;
378            if hits.len() >= max_results {
379                break;
380            }
381        }
382        Ok(hits)
383    }
384}
385
386impl ToolExecutor for SearchCodeExecutor {
387    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
388        Ok(None)
389    }
390
391    #[cfg_attr(
392        feature = "profiling",
393        tracing::instrument(name = "tool.search_code", skip_all)
394    )]
395    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
396        if call.tool_id != "search_code" {
397            return Ok(None);
398        }
399        let params: SearchCodeParams = deserialize_params(&call.params)?;
400        self.handle_search_code(&params).await
401    }
402
403    fn tool_definitions(&self) -> Vec<ToolDef> {
404        vec![ToolDef {
405            id: "search_code".into(),
406            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(),
407            schema: schemars::schema_for!(SearchCodeParams),
408            invocation: InvocationHint::ToolCall,
409        }]
410    }
411}
412
413/// Traverse `allowed_paths` collecting structural symbol hits synchronously.
414///
415/// Extracted as a free function so callers can run it inside
416/// `tokio::task::spawn_blocking` without borrowing `self`.
417fn collect_all_structural_hits(
418    allowed_paths: &[PathBuf],
419    symbol: &str,
420    file_pattern: Option<&str>,
421    max_results: usize,
422) -> Result<Vec<SearchCodeHit>, ToolError> {
423    let matcher = file_pattern
424        .map(glob::Pattern::new)
425        .transpose()
426        .map_err(|e| ToolError::InvalidParams {
427            message: format!("invalid file_pattern: {e}"),
428        })?;
429    let mut hits = Vec::new();
430    let symbol_lower = symbol.to_lowercase();
431    for root in allowed_paths {
432        collect_structural_hits(root, root, matcher.as_ref(), &symbol_lower, &mut hits)?;
433        if hits.len() >= max_results {
434            break;
435        }
436    }
437    Ok(hits)
438}
439
440fn dedupe_hits(mut hits: Vec<SearchCodeHit>, max_results: usize) -> Vec<SearchCodeHit> {
441    let mut merged: HashMap<(String, usize, usize), SearchCodeHit> = HashMap::new();
442    for hit in hits.drain(..) {
443        let key = (hit.file_path.clone(), hit.line_start, hit.line_end);
444        merged
445            .entry(key)
446            .and_modify(|existing| {
447                if hit.score > existing.score {
448                    existing.score = hit.score;
449                    existing.snippet.clone_from(&hit.snippet);
450                    existing.symbol_name = hit.symbol_name.clone().or(existing.symbol_name.clone());
451                }
452                if existing.source != hit.source {
453                    existing.source = if existing.score >= hit.score {
454                        existing.source
455                    } else {
456                        hit.source
457                    };
458                }
459            })
460            .or_insert(hit);
461    }
462
463    let mut merged = merged.into_values().collect::<Vec<_>>();
464    merged.sort_by(|a, b| {
465        b.score
466            .partial_cmp(&a.score)
467            .unwrap_or(std::cmp::Ordering::Equal)
468            .then_with(|| a.file_path.cmp(&b.file_path))
469            .then_with(|| a.line_start.cmp(&b.line_start))
470    });
471    merged.truncate(max_results);
472    merged
473}
474
475fn format_hits(hits: &[SearchCodeHit], root: &Path) -> String {
476    if hits.is_empty() {
477        return "No code matches found.".into();
478    }
479
480    hits.iter()
481        .enumerate()
482        .map(|(idx, hit)| {
483            let display_path = Path::new(&hit.file_path)
484                .strip_prefix(root)
485                .map_or_else(|_| hit.file_path.clone(), |p| p.display().to_string());
486            format!(
487                "[{}] {}:{}-{}\n    {}\n    source: {}\n    score: {:.2}",
488                idx + 1,
489                display_path,
490                hit.line_start,
491                hit.line_end,
492                hit.snippet.replace('\n', " "),
493                hit.source.label(),
494                hit.score,
495            )
496        })
497        .collect::<Vec<_>>()
498        .join("\n\n")
499}
500
501fn collect_structural_hits(
502    root: &Path,
503    current: &Path,
504    matcher: Option<&glob::Pattern>,
505    symbol_lower: &str,
506    hits: &mut Vec<SearchCodeHit>,
507) -> Result<(), ToolError> {
508    if should_skip_path(current) {
509        return Ok(());
510    }
511
512    let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
513    for entry in entries {
514        let entry = entry.map_err(ToolError::Execution)?;
515        let path = entry.path();
516        if path.is_dir() {
517            collect_structural_hits(root, &path, matcher, symbol_lower, hits)?;
518            continue;
519        }
520        if !matches_pattern(root, &path, matcher) {
521            continue;
522        }
523        let Some(info) = lang_info_for_path(&path) else {
524            continue;
525        };
526        let grammar = info.grammar;
527        let Some(query) = info.symbol_query.as_ref() else {
528            continue;
529        };
530        let Ok(source) = std::fs::read_to_string(&path) else {
531            continue;
532        };
533        let mut parser = Parser::new();
534        if parser.set_language(&grammar).is_err() {
535            continue;
536        }
537        let Some(tree) = parser.parse(&source, None) else {
538            continue;
539        };
540        let mut cursor = QueryCursor::new();
541        let capture_names = query.capture_names();
542        let def_idx = capture_names.iter().position(|name| *name == "def");
543        let name_idx = capture_names.iter().position(|name| *name == "name");
544        let (Some(def_idx), Some(name_idx)) = (def_idx, name_idx) else {
545            continue;
546        };
547
548        let mut query_matches = cursor.matches(query, tree.root_node(), source.as_bytes());
549        while let Some(match_) = query_matches.next() {
550            let mut def_node = None;
551            let mut name = None;
552            for capture in match_.captures {
553                if capture.index as usize == def_idx {
554                    def_node = Some(capture.node);
555                }
556                if capture.index as usize == name_idx {
557                    name = Some(source[capture.node.byte_range()].to_string());
558                }
559            }
560            let Some(name) = name else {
561                continue;
562            };
563            if !name.to_lowercase().contains(symbol_lower) {
564                continue;
565            }
566            let Some(def_node) = def_node else {
567                continue;
568            };
569            hits.push(SearchCodeHit {
570                file_path: canonical_string(&path),
571                line_start: def_node.start_position().row + 1,
572                line_end: def_node.end_position().row + 1,
573                snippet: extract_snippet(&source, def_node.start_position().row + 1),
574                source: SearchCodeSource::Structural,
575                score: SearchCodeSource::Structural.default_score(),
576                symbol_name: Some(name),
577            });
578        }
579    }
580    Ok(())
581}
582
583fn collect_grep_hits(
584    root: &Path,
585    current: &Path,
586    matcher: Option<&glob::Pattern>,
587    regex: &regex::Regex,
588    hits: &mut Vec<SearchCodeHit>,
589    max_results: usize,
590) -> Result<(), ToolError> {
591    if hits.len() >= max_results || should_skip_path(current) {
592        return Ok(());
593    }
594
595    let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
596    for entry in entries {
597        let entry = entry.map_err(ToolError::Execution)?;
598        let path = entry.path();
599        if path.is_dir() {
600            collect_grep_hits(root, &path, matcher, regex, hits, max_results)?;
601            continue;
602        }
603        if !matches_pattern(root, &path, matcher) {
604            continue;
605        }
606        let Ok(source) = std::fs::read_to_string(&path) else {
607            continue;
608        };
609        for (idx, line) in source.lines().enumerate() {
610            if regex.is_match(line) {
611                hits.push(SearchCodeHit {
612                    file_path: canonical_string(&path),
613                    line_start: idx + 1,
614                    line_end: idx + 1,
615                    snippet: line.trim().to_string(),
616                    source: SearchCodeSource::GrepFallback,
617                    score: SearchCodeSource::GrepFallback.default_score(),
618                    symbol_name: None,
619                });
620                if hits.len() >= max_results {
621                    return Ok(());
622                }
623            }
624        }
625    }
626    Ok(())
627}
628
629fn matches_pattern(root: &Path, path: &Path, matcher: Option<&glob::Pattern>) -> bool {
630    let Some(matcher) = matcher else {
631        return true;
632    };
633    let relative = path.strip_prefix(root).unwrap_or(path);
634    matcher.matches_path(relative)
635}
636
637fn should_skip_path(path: &Path) -> bool {
638    path.file_name()
639        .and_then(|name| name.to_str())
640        .is_some_and(|name| matches!(name, ".git" | "target" | "node_modules" | ".zeph"))
641}
642
643fn canonical_string(path: &Path) -> String {
644    path.canonicalize()
645        .unwrap_or_else(|_| path.to_path_buf())
646        .display()
647        .to_string()
648}
649
650fn extract_snippet(source: &str, line_number: usize) -> String {
651    source
652        .lines()
653        .nth(line_number.saturating_sub(1))
654        .map(str::trim)
655        .unwrap_or_default()
656        .to_string()
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    struct EmptySemantic;
664
665    impl SemanticSearchBackend for EmptySemantic {
666        fn search<'a>(
667            &'a self,
668            _query: &'a str,
669            _file_pattern: Option<&'a str>,
670            _max_results: usize,
671        ) -> Pin<
672            Box<
673                dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a,
674            >,
675        > {
676            Box::pin(async move { Ok(vec![]) })
677        }
678    }
679
680    #[tokio::test]
681    async fn search_code_requires_query_or_symbol() {
682        let dir = tempfile::tempdir().unwrap();
683        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
684        let call = ToolCall {
685            tool_id: "search_code".into(),
686            params: serde_json::Map::new(),
687            caller_id: None,
688        };
689        let err = exec.execute_tool_call(&call).await.unwrap_err();
690        assert!(matches!(err, ToolError::InvalidParams { .. }));
691    }
692
693    #[tokio::test]
694    async fn search_code_finds_structural_symbol() {
695        let dir = tempfile::tempdir().unwrap();
696        let file = dir.path().join("lib.rs");
697        std::fs::write(&file, "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
698        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
699        let call = ToolCall {
700            tool_id: "search_code".into(),
701            params: serde_json::json!({ "symbol": "retry_backoff_ms" })
702                .as_object()
703                .unwrap()
704                .clone(),
705            caller_id: None,
706        };
707        let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
708        assert!(out.summary.contains("retry_backoff_ms"));
709        assert!(out.summary.contains("tree-sitter"));
710        assert_eq!(out.tool_name, "search_code");
711    }
712
713    #[tokio::test]
714    async fn search_code_uses_grep_fallback() {
715        let dir = tempfile::tempdir().unwrap();
716        let file = dir.path().join("mod.rs");
717        std::fs::write(&file, "let retry_backoff_ms = 5;\n").unwrap();
718        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
719        let call = ToolCall {
720            tool_id: "search_code".into(),
721            params: serde_json::json!({ "query": "retry_backoff_ms" })
722                .as_object()
723                .unwrap()
724                .clone(),
725            caller_id: None,
726        };
727        let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
728        assert!(out.summary.contains("grep fallback"));
729    }
730
731    #[test]
732    fn tool_definitions_include_search_code() {
733        let exec = SearchCodeExecutor::new(vec![])
734            .with_semantic_backend(std::sync::Arc::new(EmptySemantic));
735        let defs = exec.tool_definitions();
736        assert_eq!(defs.len(), 1);
737        assert_eq!(defs[0].id.as_ref(), "search_code");
738    }
739
740    #[test]
741    fn format_hits_strips_root_prefix() {
742        let root = Path::new("/tmp/myproject");
743        let hits = vec![SearchCodeHit {
744            file_path: "/tmp/myproject/crates/foo/src/lib.rs".to_owned(),
745            line_start: 10,
746            line_end: 15,
747            snippet: "pub fn example() {}".to_owned(),
748            source: SearchCodeSource::GrepFallback,
749            score: 0.45,
750            symbol_name: None,
751        }];
752        let output = format_hits(&hits, root);
753        assert!(
754            output.contains("crates/foo/src/lib.rs"),
755            "expected relative path in output, got: {output}"
756        );
757        assert!(
758            !output.contains("/tmp/myproject"),
759            "absolute path must not appear in output, got: {output}"
760        );
761    }
762
763    /// `search_code` description must explicitly state it is not for user-provided facts
764    /// so the model does not use it when recalling conversation context (#2475).
765    #[tokio::test]
766    async fn search_code_description_excludes_user_facts() {
767        let dir = tempfile::tempdir().unwrap();
768        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
769        let defs = exec.tool_definitions();
770        let search_code = defs
771            .iter()
772            .find(|d| d.id.as_ref() == "search_code")
773            .unwrap();
774        assert!(
775            search_code
776                .description
777                .contains("not for user-provided facts"),
778            "search_code description must contain disambiguation phrase; got: {}",
779            search_code.description
780        );
781    }
782}