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