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