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            output_schema: None,
410        }]
411    }
412}
413
414/// Traverse `allowed_paths` collecting structural symbol hits synchronously.
415///
416/// Extracted as a free function so callers can run it inside
417/// `tokio::task::spawn_blocking` without borrowing `self`.
418fn collect_all_structural_hits(
419    allowed_paths: &[PathBuf],
420    symbol: &str,
421    file_pattern: Option<&str>,
422    max_results: usize,
423) -> Result<Vec<SearchCodeHit>, ToolError> {
424    let matcher = file_pattern
425        .map(glob::Pattern::new)
426        .transpose()
427        .map_err(|e| ToolError::InvalidParams {
428            message: format!("invalid file_pattern: {e}"),
429        })?;
430    let mut hits = Vec::new();
431    let symbol_lower = symbol.to_lowercase();
432    for root in allowed_paths {
433        collect_structural_hits(root, root, matcher.as_ref(), &symbol_lower, &mut hits)?;
434        if hits.len() >= max_results {
435            break;
436        }
437    }
438    Ok(hits)
439}
440
441fn dedupe_hits(mut hits: Vec<SearchCodeHit>, max_results: usize) -> Vec<SearchCodeHit> {
442    let mut merged: HashMap<(String, usize, usize), SearchCodeHit> = HashMap::new();
443    for hit in hits.drain(..) {
444        let key = (hit.file_path.clone(), hit.line_start, hit.line_end);
445        merged
446            .entry(key)
447            .and_modify(|existing| {
448                if hit.score > existing.score {
449                    existing.score = hit.score;
450                    existing.snippet.clone_from(&hit.snippet);
451                    existing.symbol_name = hit.symbol_name.clone().or(existing.symbol_name.clone());
452                }
453                if existing.source != hit.source {
454                    existing.source = if existing.score >= hit.score {
455                        existing.source
456                    } else {
457                        hit.source
458                    };
459                }
460            })
461            .or_insert(hit);
462    }
463
464    let mut merged = merged.into_values().collect::<Vec<_>>();
465    merged.sort_by(|a, b| {
466        b.score
467            .partial_cmp(&a.score)
468            .unwrap_or(std::cmp::Ordering::Equal)
469            .then_with(|| a.file_path.cmp(&b.file_path))
470            .then_with(|| a.line_start.cmp(&b.line_start))
471    });
472    merged.truncate(max_results);
473    merged
474}
475
476fn format_hits(hits: &[SearchCodeHit], root: &Path) -> String {
477    if hits.is_empty() {
478        return "No code matches found.".into();
479    }
480
481    hits.iter()
482        .enumerate()
483        .map(|(idx, hit)| {
484            let display_path = Path::new(&hit.file_path)
485                .strip_prefix(root)
486                .map_or_else(|_| hit.file_path.clone(), |p| p.display().to_string());
487            format!(
488                "[{}] {}:{}-{}\n    {}\n    source: {}\n    score: {:.2}",
489                idx + 1,
490                display_path,
491                hit.line_start,
492                hit.line_end,
493                hit.snippet.replace('\n', " "),
494                hit.source.label(),
495                hit.score,
496            )
497        })
498        .collect::<Vec<_>>()
499        .join("\n\n")
500}
501
502fn collect_structural_hits(
503    root: &Path,
504    current: &Path,
505    matcher: Option<&glob::Pattern>,
506    symbol_lower: &str,
507    hits: &mut Vec<SearchCodeHit>,
508) -> Result<(), ToolError> {
509    if should_skip_path(current) {
510        return Ok(());
511    }
512
513    let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
514    for entry in entries {
515        let entry = entry.map_err(ToolError::Execution)?;
516        let path = entry.path();
517        if path.is_dir() {
518            collect_structural_hits(root, &path, matcher, symbol_lower, hits)?;
519            continue;
520        }
521        if !matches_pattern(root, &path, matcher) {
522            continue;
523        }
524        let Some(info) = lang_info_for_path(&path) else {
525            continue;
526        };
527        let grammar = info.grammar;
528        let Some(query) = info.symbol_query.as_ref() else {
529            continue;
530        };
531        let Ok(source) = std::fs::read_to_string(&path) else {
532            continue;
533        };
534        let mut parser = Parser::new();
535        if parser.set_language(&grammar).is_err() {
536            continue;
537        }
538        let Some(tree) = parser.parse(&source, None) else {
539            continue;
540        };
541        let mut cursor = QueryCursor::new();
542        let capture_names = query.capture_names();
543        let def_idx = capture_names.iter().position(|name| *name == "def");
544        let name_idx = capture_names.iter().position(|name| *name == "name");
545        let (Some(def_idx), Some(name_idx)) = (def_idx, name_idx) else {
546            continue;
547        };
548
549        let mut query_matches = cursor.matches(query, tree.root_node(), source.as_bytes());
550        while let Some(match_) = query_matches.next() {
551            let mut def_node = None;
552            let mut name = None;
553            for capture in match_.captures {
554                if capture.index as usize == def_idx {
555                    def_node = Some(capture.node);
556                }
557                if capture.index as usize == name_idx {
558                    name = Some(source[capture.node.byte_range()].to_string());
559                }
560            }
561            let Some(name) = name else {
562                continue;
563            };
564            if !name.to_lowercase().contains(symbol_lower) {
565                continue;
566            }
567            let Some(def_node) = def_node else {
568                continue;
569            };
570            hits.push(SearchCodeHit {
571                file_path: canonical_string(&path),
572                line_start: def_node.start_position().row + 1,
573                line_end: def_node.end_position().row + 1,
574                snippet: extract_snippet(&source, def_node.start_position().row + 1),
575                source: SearchCodeSource::Structural,
576                score: SearchCodeSource::Structural.default_score(),
577                symbol_name: Some(name),
578            });
579        }
580    }
581    Ok(())
582}
583
584fn collect_grep_hits(
585    root: &Path,
586    current: &Path,
587    matcher: Option<&glob::Pattern>,
588    regex: &regex::Regex,
589    hits: &mut Vec<SearchCodeHit>,
590    max_results: usize,
591) -> Result<(), ToolError> {
592    if hits.len() >= max_results || should_skip_path(current) {
593        return Ok(());
594    }
595
596    let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
597    for entry in entries {
598        let entry = entry.map_err(ToolError::Execution)?;
599        let path = entry.path();
600        if path.is_dir() {
601            collect_grep_hits(root, &path, matcher, regex, hits, max_results)?;
602            continue;
603        }
604        if !matches_pattern(root, &path, matcher) {
605            continue;
606        }
607        let Ok(source) = std::fs::read_to_string(&path) else {
608            continue;
609        };
610        for (idx, line) in source.lines().enumerate() {
611            if regex.is_match(line) {
612                hits.push(SearchCodeHit {
613                    file_path: canonical_string(&path),
614                    line_start: idx + 1,
615                    line_end: idx + 1,
616                    snippet: line.trim().to_string(),
617                    source: SearchCodeSource::GrepFallback,
618                    score: SearchCodeSource::GrepFallback.default_score(),
619                    symbol_name: None,
620                });
621                if hits.len() >= max_results {
622                    return Ok(());
623                }
624            }
625        }
626    }
627    Ok(())
628}
629
630fn matches_pattern(root: &Path, path: &Path, matcher: Option<&glob::Pattern>) -> bool {
631    let Some(matcher) = matcher else {
632        return true;
633    };
634    let relative = path.strip_prefix(root).unwrap_or(path);
635    matcher.matches_path(relative)
636}
637
638fn should_skip_path(path: &Path) -> bool {
639    path.file_name()
640        .and_then(|name| name.to_str())
641        .is_some_and(|name| matches!(name, ".git" | "target" | "node_modules" | ".zeph"))
642}
643
644fn canonical_string(path: &Path) -> String {
645    path.canonicalize()
646        .unwrap_or_else(|_| path.to_path_buf())
647        .display()
648        .to_string()
649}
650
651fn extract_snippet(source: &str, line_number: usize) -> String {
652    source
653        .lines()
654        .nth(line_number.saturating_sub(1))
655        .map(str::trim)
656        .unwrap_or_default()
657        .to_string()
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    struct EmptySemantic;
665
666    impl SemanticSearchBackend for EmptySemantic {
667        fn search<'a>(
668            &'a self,
669            _query: &'a str,
670            _file_pattern: Option<&'a str>,
671            _max_results: usize,
672        ) -> Pin<
673            Box<
674                dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a,
675            >,
676        > {
677            Box::pin(async move { Ok(vec![]) })
678        }
679    }
680
681    #[tokio::test]
682    async fn search_code_requires_query_or_symbol() {
683        let dir = tempfile::tempdir().unwrap();
684        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
685        let call = ToolCall {
686            tool_id: "search_code".into(),
687            params: serde_json::Map::new(),
688            caller_id: None,
689        };
690        let err = exec.execute_tool_call(&call).await.unwrap_err();
691        assert!(matches!(err, ToolError::InvalidParams { .. }));
692    }
693
694    #[tokio::test]
695    async fn search_code_finds_structural_symbol() {
696        let dir = tempfile::tempdir().unwrap();
697        let file = dir.path().join("lib.rs");
698        std::fs::write(&file, "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
699        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
700        let call = ToolCall {
701            tool_id: "search_code".into(),
702            params: serde_json::json!({ "symbol": "retry_backoff_ms" })
703                .as_object()
704                .unwrap()
705                .clone(),
706            caller_id: None,
707        };
708        let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
709        assert!(out.summary.contains("retry_backoff_ms"));
710        assert!(out.summary.contains("tree-sitter"));
711        assert_eq!(out.tool_name, "search_code");
712    }
713
714    #[tokio::test]
715    async fn search_code_uses_grep_fallback() {
716        let dir = tempfile::tempdir().unwrap();
717        let file = dir.path().join("mod.rs");
718        std::fs::write(&file, "let retry_backoff_ms = 5;\n").unwrap();
719        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
720        let call = ToolCall {
721            tool_id: "search_code".into(),
722            params: serde_json::json!({ "query": "retry_backoff_ms" })
723                .as_object()
724                .unwrap()
725                .clone(),
726            caller_id: None,
727        };
728        let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
729        assert!(out.summary.contains("grep fallback"));
730    }
731
732    #[test]
733    fn tool_definitions_include_search_code() {
734        let exec = SearchCodeExecutor::new(vec![])
735            .with_semantic_backend(std::sync::Arc::new(EmptySemantic));
736        let defs = exec.tool_definitions();
737        assert_eq!(defs.len(), 1);
738        assert_eq!(defs[0].id.as_ref(), "search_code");
739    }
740
741    #[test]
742    fn format_hits_strips_root_prefix() {
743        let root = Path::new("/tmp/myproject");
744        let hits = vec![SearchCodeHit {
745            file_path: "/tmp/myproject/crates/foo/src/lib.rs".to_owned(),
746            line_start: 10,
747            line_end: 15,
748            snippet: "pub fn example() {}".to_owned(),
749            source: SearchCodeSource::GrepFallback,
750            score: 0.45,
751            symbol_name: None,
752        }];
753        let output = format_hits(&hits, root);
754        assert!(
755            output.contains("crates/foo/src/lib.rs"),
756            "expected relative path in output, got: {output}"
757        );
758        assert!(
759            !output.contains("/tmp/myproject"),
760            "absolute path must not appear in output, got: {output}"
761        );
762    }
763
764    /// `search_code` description must explicitly state it is not for user-provided facts
765    /// so the model does not use it when recalling conversation context (#2475).
766    #[tokio::test]
767    async fn search_code_description_excludes_user_facts() {
768        let dir = tempfile::tempdir().unwrap();
769        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
770        let defs = exec.tool_definitions();
771        let search_code = defs
772            .iter()
773            .find(|d| d.id.as_ref() == "search_code")
774            .unwrap();
775        assert!(
776            search_code
777                .description
778                .contains("not for user-provided facts"),
779            "search_code description must contain disambiguation phrase; got: {}",
780            search_code.description
781        );
782    }
783}