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