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::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
14use crate::registry::{InvocationHint, ToolDef};
15
16use 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 #[serde(default)]
175 query: Option<String>,
176 #[serde(default)]
178 symbol: Option<String>,
179 #[serde(default)]
181 file_pattern: Option<String>,
182 #[serde(default)]
184 include_references: bool,
185 #[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(), ®ex, &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(¶ms).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: ®ex::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}