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