1use 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
18use 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 #[serde(default)]
177 query: Option<String>,
178 #[serde(default)]
180 symbol: Option<String>,
181 #[serde(default)]
183 file_pattern: Option<String>,
184 #[serde(default)]
186 include_references: bool,
187 #[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(), ®ex, &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(¶ms).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: ®ex::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}