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