1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use ignore::WalkBuilder;
4use regex::Regex;
5use std::fs;
6use std::path::PathBuf;
7
8#[derive(Debug)]
10pub struct SearchMatch {
11 pub file_path: String,
12 pub line_number: usize,
13 pub line_content: String,
14 pub context_before: Vec<String>,
15 pub context_after: Vec<String>,
16}
17
18#[derive(Debug, Clone, Deserialize, JsonSchema)]
20pub struct SearchInput {
21 pub root_path: PathBuf,
23
24 pub pattern: String,
26
27 #[serde(default = "default_search_type")]
29 pub search_type: String,
30
31 #[serde(default)]
33 pub file_pattern: Option<String>,
34
35 #[serde(default = "default_ignore_case")]
37 pub ignore_case: bool,
38
39 #[serde(default = "default_max_results")]
41 pub max_results: usize,
42
43 #[serde(default)]
45 pub include_hidden: bool,
46
47 #[serde(default)]
49 pub context_lines: usize,
50
51 #[serde(default)]
53 pub literal_search: bool,
54}
55
56fn default_search_type() -> String {
57 "content".to_string()
58}
59
60fn default_ignore_case() -> bool {
61 true
62}
63
64fn default_max_results() -> usize {
65 100
66}
67
68pub struct SearchTool {
70 base_path: PathBuf,
71}
72
73impl Default for SearchTool {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl SearchTool {
80 pub fn new() -> Self {
82 Self {
83 base_path: std::env::current_dir().expect("Failed to get current working directory"),
84 }
85 }
86
87 pub fn with_base_path(base_path: PathBuf) -> Self {
89 Self { base_path }
90 }
91
92 fn search_file_contents(
93 &self,
94 file_path: &PathBuf,
95 pattern: &Regex,
96 context_lines: usize,
97 ) -> std::result::Result<Vec<SearchMatch>, ToolError> {
98 let content = fs::read_to_string(file_path).map_err(|e| {
99 ToolError::from(format!("Failed to read {}: {}", file_path.display(), e))
100 })?;
101
102 let lines: Vec<&str> = content.lines().collect();
103 let mut matches = Vec::new();
104
105 for (line_idx, line) in lines.iter().enumerate() {
106 if pattern.is_match(line) {
107 let context_before = if context_lines > 0 {
108 let start = line_idx.saturating_sub(context_lines);
109 lines[start..line_idx]
110 .iter()
111 .map(|s| s.to_string())
112 .collect()
113 } else {
114 Vec::new()
115 };
116
117 let context_after = if context_lines > 0 {
118 let end = (line_idx + 1 + context_lines).min(lines.len());
119 lines[line_idx + 1..end]
120 .iter()
121 .map(|s| s.to_string())
122 .collect()
123 } else {
124 Vec::new()
125 };
126
127 matches.push(SearchMatch {
128 file_path: file_path.display().to_string(),
129 line_number: line_idx + 1, line_content: line.to_string(),
131 context_before,
132 context_after,
133 });
134 }
135 }
136
137 Ok(matches)
138 }
139
140 fn search_filenames(
141 &self,
142 root_path: &PathBuf,
143 pattern: &Regex,
144 include_hidden: bool,
145 max_results: usize,
146 ) -> std::result::Result<Vec<String>, ToolError> {
147 let walker = WalkBuilder::new(root_path)
148 .hidden(!include_hidden)
149 .git_ignore(true)
150 .max_depth(Some(50))
151 .build();
152
153 let mut matches = Vec::new();
154
155 for entry in walker {
156 if matches.len() >= max_results {
157 break;
158 }
159
160 let entry =
161 entry.map_err(|e| ToolError::from(format!("Error walking directory: {}", e)))?;
162
163 if let Some(file_name) = entry.file_name().to_str() {
164 if pattern.is_match(file_name) {
165 if let Ok(relative_path) = entry.path().strip_prefix(root_path) {
166 matches.push(relative_path.display().to_string());
167 }
168 }
169 }
170 }
171
172 Ok(matches)
173 }
174}
175
176impl Tool for SearchTool {
177 type Input = SearchInput;
178
179 fn name(&self) -> &str {
180 "search"
181 }
182
183 fn description(&self) -> &str {
184 "Search for text patterns in files (content search) or search filenames. \
185 Uses regex patterns and respects .gitignore. Can show context lines around matches."
186 }
187
188 async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
189 let root_path = validate_path(&self.base_path, &input.root_path)
190 .map_err(|e| ToolError::from(e.to_string()))?;
191
192 let pattern_str = if input.literal_search {
194 regex::escape(&input.pattern)
195 } else {
196 input.pattern.clone()
197 };
198
199 let regex_pattern = if input.ignore_case {
200 Regex::new(&format!("(?i){}", pattern_str))
201 } else {
202 Regex::new(&pattern_str)
203 }
204 .map_err(|e| ToolError::from(format!("Invalid regex pattern: {}", e)))?;
205
206 let file_glob = if let Some(ref pattern) = input.file_pattern {
208 Some(
209 glob::Pattern::new(pattern)
210 .map_err(|e| ToolError::from(format!("Invalid file pattern: {}", e)))?,
211 )
212 } else {
213 None
214 };
215
216 match input.search_type.as_str() {
217 "files" => {
218 let matches = self.search_filenames(
220 &root_path,
221 ®ex_pattern,
222 input.include_hidden,
223 input.max_results,
224 )?;
225
226 let content = if matches.is_empty() {
227 format!(
228 "No files matching '{}' found in {}",
229 input.pattern,
230 input.root_path.display()
231 )
232 } else {
233 format!(
234 "Found {} file(s) matching '{}':\n{}",
235 matches.len(),
236 input.pattern,
237 matches.join("\n")
238 )
239 };
240
241 Ok(content.into())
242 }
243 "content" => {
244 let walker = WalkBuilder::new(&root_path)
246 .hidden(!input.include_hidden)
247 .git_ignore(true)
248 .max_depth(Some(50))
249 .build();
250
251 let mut all_matches = Vec::new();
252
253 for entry in walker {
254 if all_matches.len() >= input.max_results {
255 break;
256 }
257
258 let entry = entry
259 .map_err(|e| ToolError::from(format!("Error walking directory: {}", e)))?;
260
261 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
263 continue;
264 }
265
266 if let Some(ref glob_pattern) = file_glob {
268 if let Some(file_name) = entry.file_name().to_str() {
269 if !glob_pattern.matches(file_name) {
270 continue;
271 }
272 }
273 }
274
275 match self.search_file_contents(
277 &entry.path().to_path_buf(),
278 ®ex_pattern,
279 input.context_lines,
280 ) {
281 Ok(matches) => {
282 for m in matches {
283 if all_matches.len() >= input.max_results {
284 break;
285 }
286 all_matches.push(m);
287 }
288 }
289 Err(_) => {
290 continue;
292 }
293 }
294 }
295
296 let content = if all_matches.is_empty() {
297 format!(
298 "No matches for '{}' found in {}",
299 input.pattern,
300 input.root_path.display()
301 )
302 } else {
303 let mut result = format!(
304 "Found {} match(es) for '{}':\n\n",
305 all_matches.len(),
306 input.pattern
307 );
308
309 for m in all_matches {
310 result.push_str(&format!("{}:{}\n", m.file_path, m.line_number));
311
312 for ctx in &m.context_before {
314 result.push_str(&format!(" | {}\n", ctx));
315 }
316
317 result.push_str(&format!(" > {}\n", m.line_content));
319
320 for ctx in &m.context_after {
322 result.push_str(&format!(" | {}\n", ctx));
323 }
324
325 result.push('\n');
326 }
327
328 result
329 };
330
331 Ok(content.into())
332 }
333 _ => Err(format!(
334 "Invalid search_type: '{}'. Must be 'files' or 'content'",
335 input.search_type
336 )
337 .into()),
338 }
339 }
340
341 fn format_output_plain(&self, result: &ToolResult) -> String {
342 let output = result.as_text();
343 let lines: Vec<&str> = output.lines().collect();
344 if lines.is_empty() || output.starts_with("No matches") || output.starts_with("No files") {
345 return output.to_string();
346 }
347 if output.starts_with("Found") && output.contains("file(s)") {
348 return output.to_string();
349 }
350
351 let mut out = String::new();
352 let mut current_file: Option<&str> = None;
353
354 for line in lines {
355 if line.starts_with("Found ") {
356 out.push_str(line);
357 out.push_str("\n\n");
358 continue;
359 }
360 if let Some(colon_idx) = line.find(':') {
361 let potential_file = &line[..colon_idx];
362 if !line.starts_with(" ")
363 && (potential_file.contains('/') || potential_file.contains('.'))
364 {
365 if current_file != Some(potential_file) {
366 if current_file.is_some() {
367 out.push('\n');
368 }
369 out.push_str(potential_file);
370 out.push('\n');
371 current_file = Some(potential_file);
372 }
373 let rest = &line[colon_idx + 1..];
374 if let Some(content_start) = rest.find(|c: char| !c.is_ascii_digit()) {
375 out.push_str(&format!(
376 " {}:{}\n",
377 &rest[..content_start],
378 &rest[content_start..]
379 ));
380 } else {
381 out.push_str(&format!(" {}\n", rest));
382 }
383 } else {
384 out.push_str(line);
385 out.push('\n');
386 }
387 } else if line.starts_with(" >") {
388 out.push_str(&format!(" → {}\n", &line[4..]));
389 } else if line.starts_with(" |") {
390 out.push_str(&format!(" {}\n", &line[4..]));
391 } else if !line.is_empty() {
392 out.push_str(line);
393 out.push('\n');
394 }
395 }
396 out
397 }
398
399 fn format_output_ansi(&self, result: &ToolResult) -> String {
400 let output = result.as_text();
401 let lines: Vec<&str> = output.lines().collect();
402 if lines.is_empty() {
403 return output.to_string();
404 }
405 if output.starts_with("No matches") || output.starts_with("No files") {
406 return format!("\x1b[2m{}\x1b[0m", output);
407 }
408 if output.starts_with("Found") && output.contains("file(s)") {
409 let mut out = String::new();
410 for line in lines {
411 if line.starts_with("Found") {
412 out.push_str(&format!("\x1b[1m{}\x1b[0m\n", line));
413 } else {
414 out.push_str(&format!("\x1b[35m{}\x1b[0m\n", line));
415 }
416 }
417 return out;
418 }
419
420 let mut out = String::new();
421 let mut current_file: Option<&str> = None;
422
423 for line in lines {
424 if line.starts_with("Found ") {
425 out.push_str(&format!("\x1b[1m{}\x1b[0m\n\n", line));
426 continue;
427 }
428 if let Some(colon_idx) = line.find(':') {
429 let potential_file = &line[..colon_idx];
430 if !line.starts_with(" ")
431 && (potential_file.contains('/') || potential_file.contains('.'))
432 {
433 if current_file != Some(potential_file) {
434 if current_file.is_some() {
435 out.push('\n');
436 }
437 out.push_str(&format!("\x1b[35m{}\x1b[0m\n", potential_file));
438 current_file = Some(potential_file);
439 }
440 let rest = &line[colon_idx + 1..];
441 if let Some(content_start) = rest.find(|c: char| !c.is_ascii_digit()) {
442 out.push_str(&format!(
443 "\x1b[32m{}\x1b[0m:{}\n",
444 &rest[..content_start],
445 &rest[content_start..]
446 ));
447 } else {
448 out.push_str(&format!(" {}\n", rest));
449 }
450 } else {
451 out.push_str(line);
452 out.push('\n');
453 }
454 } else if line.starts_with(" >") {
455 out.push_str(&format!("\x1b[33m→\x1b[0m {}\n", &line[4..]));
456 } else if line.starts_with(" |") {
457 out.push_str(&format!("\x1b[2m {}\x1b[0m\n", &line[4..]));
458 } else if !line.is_empty() {
459 out.push_str(line);
460 out.push('\n');
461 }
462 }
463 out
464 }
465
466 fn format_output_markdown(&self, result: &ToolResult) -> String {
467 let output = result.as_text();
468 let lines: Vec<&str> = output.lines().collect();
469 if lines.is_empty() {
470 return output.to_string();
471 }
472 if output.starts_with("No matches") || output.starts_with("No files") {
473 return format!("*{}*", output);
474 }
475 if output.starts_with("Found") && output.contains("file(s)") {
476 let mut out = String::new();
477 for line in lines {
478 if line.starts_with("Found") {
479 out.push_str(&format!("**{}**\n\n", line));
480 } else {
481 out.push_str(&format!("- `{}`\n", line));
482 }
483 }
484 return out;
485 }
486
487 let mut out = String::new();
488 let mut current_file: Option<&str> = None;
489 let mut in_code_block = false;
490
491 for line in lines {
492 if line.starts_with("Found ") {
493 out.push_str(&format!("**{}**\n\n", line));
494 continue;
495 }
496 if let Some(colon_idx) = line.find(':') {
497 let potential_file = &line[..colon_idx];
498 if !line.starts_with(" ")
499 && (potential_file.contains('/') || potential_file.contains('.'))
500 {
501 if current_file != Some(potential_file) {
502 if in_code_block {
503 out.push_str("```\n\n");
504 }
505 out.push_str(&format!("### `{}`\n```\n", potential_file));
506 in_code_block = true;
507 current_file = Some(potential_file);
508 }
509 out.push_str(&format!("{}\n", &line[colon_idx + 1..]));
510 } else {
511 out.push_str(line);
512 out.push('\n');
513 }
514 } else if line.starts_with(" >") || line.starts_with(" |") {
515 out.push_str(&format!("{}\n", &line[2..]));
516 } else if !line.is_empty() {
517 out.push_str(line);
518 out.push('\n');
519 }
520 }
521 if in_code_block {
522 out.push_str("```\n");
523 }
524 out
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use std::fs;
532 use tempfile::TempDir;
533
534 #[test]
537 fn test_default() {
538 let tool: SearchTool = Default::default();
539 assert_eq!(tool.name(), "search");
540 }
541
542 #[test]
543 fn test_tool_name() {
544 let tool = SearchTool::new();
545 assert_eq!(tool.name(), "search");
546 }
547
548 #[test]
549 fn test_tool_description() {
550 let tool = SearchTool::new();
551 assert!(!tool.description().is_empty());
552 assert!(tool.description().contains("Search"));
553 }
554
555 #[test]
558 fn test_default_search_type() {
559 assert_eq!(default_search_type(), "content");
560 }
561
562 #[test]
563 fn test_default_ignore_case() {
564 assert!(default_ignore_case());
565 }
566
567 #[test]
568 fn test_default_max_results() {
569 assert_eq!(default_max_results(), 100);
570 }
571
572 #[test]
575 fn test_format_output_plain_no_matches() {
576 let tool = SearchTool::new();
577 let result: ToolResult = "No matches for 'pattern' found in .".into();
578
579 let formatted = tool.format_output_plain(&result);
580 assert_eq!(formatted, "No matches for 'pattern' found in .");
581 }
582
583 #[test]
584 fn test_format_output_plain_no_files() {
585 let tool = SearchTool::new();
586 let result: ToolResult = "No files matching 'pattern' found in .".into();
587
588 let formatted = tool.format_output_plain(&result);
589 assert_eq!(formatted, "No files matching 'pattern' found in .");
590 }
591
592 #[test]
593 fn test_format_output_plain_file_search() {
594 let tool = SearchTool::new();
595 let result: ToolResult = "Found 2 file(s) matching '*.rs':\ntest1.rs\ntest2.rs".into();
596
597 let formatted = tool.format_output_plain(&result);
598 assert!(formatted.contains("Found 2 file(s)"));
599 assert!(formatted.contains("test1.rs"));
600 assert!(formatted.contains("test2.rs"));
601 }
602
603 #[test]
604 fn test_format_output_plain_content_search() {
605 let tool = SearchTool::new();
606 let result: ToolResult =
607 "Found 1 match(es) for 'test':\n\nsrc/main.rs:10\n > fn test() {}".into();
608
609 let formatted = tool.format_output_plain(&result);
610 assert!(formatted.contains("Found 1 match"));
611 assert!(formatted.contains("src/main.rs"));
612 assert!(formatted.contains("→") || formatted.contains(">"));
614 }
615
616 #[test]
617 fn test_format_output_plain_with_context() {
618 let tool = SearchTool::new();
619 let result: ToolResult = "Found 1 match(es) for 'target':\n\ntest.txt:3\n | line before\n > target line\n | line after".into();
620
621 let formatted = tool.format_output_plain(&result);
622 assert!(formatted.contains("line before"));
623 assert!(formatted.contains("target line"));
624 assert!(formatted.contains("line after"));
625 }
626
627 #[test]
630 fn test_format_output_ansi_no_matches() {
631 let tool = SearchTool::new();
632 let result: ToolResult = "No matches for 'pattern' found in .".into();
633
634 let formatted = tool.format_output_ansi(&result);
635 assert!(formatted.contains("\x1b[2m"));
637 assert!(formatted.contains("No matches"));
638 }
639
640 #[test]
641 fn test_format_output_ansi_no_files() {
642 let tool = SearchTool::new();
643 let result: ToolResult = "No files matching 'pattern' found in .".into();
644
645 let formatted = tool.format_output_ansi(&result);
646 assert!(formatted.contains("\x1b[2m")); }
648
649 #[test]
650 fn test_format_output_ansi_file_search() {
651 let tool = SearchTool::new();
652 let result: ToolResult = "Found 2 file(s) matching '*.rs':\ntest1.rs\ntest2.rs".into();
653
654 let formatted = tool.format_output_ansi(&result);
655 assert!(formatted.contains("\x1b[1m"));
657 assert!(formatted.contains("\x1b[35m"));
659 }
660
661 #[test]
662 fn test_format_output_ansi_content_search() {
663 let tool = SearchTool::new();
664 let result: ToolResult =
666 "Found 1 match(es) for 'test':\n\nsrc/main.rs:10:fn test() {}".into();
667
668 let formatted = tool.format_output_ansi(&result);
669 assert!(formatted.contains("\x1b[1m"));
671 assert!(formatted.contains("\x1b[35m"));
673 assert!(formatted.contains("\x1b[32m"));
675 }
676
677 #[test]
678 fn test_format_output_ansi_match_indicator() {
679 let tool = SearchTool::new();
680 let result: ToolResult =
681 "Found 1 match(es) for 'test':\n\ntest.txt:10\n > fn test() {}".into();
682
683 let formatted = tool.format_output_ansi(&result);
684 assert!(formatted.contains("\x1b[33m"));
686 }
687
688 #[test]
689 fn test_format_output_ansi_with_context() {
690 let tool = SearchTool::new();
691 let result: ToolResult =
692 "Found 1 match(es) for 'target':\n\ntest.txt:3\n | context line\n > target line"
693 .into();
694
695 let formatted = tool.format_output_ansi(&result);
696 assert!(formatted.contains("\x1b[2m"));
698 }
699
700 #[test]
703 fn test_format_output_markdown_no_matches() {
704 let tool = SearchTool::new();
705 let result: ToolResult = "No matches for 'pattern' found in .".into();
706
707 let formatted = tool.format_output_markdown(&result);
708 assert!(formatted.contains("*No matches"));
710 }
711
712 #[test]
713 fn test_format_output_markdown_no_files() {
714 let tool = SearchTool::new();
715 let result: ToolResult = "No files matching 'pattern' found in .".into();
716
717 let formatted = tool.format_output_markdown(&result);
718 assert!(formatted.contains("*No files"));
719 }
720
721 #[test]
722 fn test_format_output_markdown_file_search() {
723 let tool = SearchTool::new();
724 let result: ToolResult = "Found 2 file(s) matching '*.rs':\ntest1.rs\ntest2.rs".into();
725
726 let formatted = tool.format_output_markdown(&result);
727 assert!(formatted.contains("**Found 2 file(s)"));
729 assert!(formatted.contains("- `test1.rs`"));
731 assert!(formatted.contains("- `test2.rs`"));
732 }
733
734 #[test]
735 fn test_format_output_markdown_content_search() {
736 let tool = SearchTool::new();
737 let result: ToolResult =
738 "Found 1 match(es) for 'test':\n\nsrc/main.rs:10\n > fn test() {}".into();
739
740 let formatted = tool.format_output_markdown(&result);
741 assert!(formatted.contains("**Found 1 match"));
743 assert!(formatted.contains("### `src/main.rs`"));
745 assert!(formatted.contains("```"));
747 }
748
749 #[test]
750 fn test_format_output_markdown_closes_code_block() {
751 let tool = SearchTool::new();
752 let result: ToolResult =
753 "Found 1 match(es) for 'test':\n\nsrc/main.rs:10\n > fn test() {}".into();
754
755 let formatted = tool.format_output_markdown(&result);
756 let open_count = formatted.matches("```").count();
758 assert!(open_count >= 2 || open_count == 0);
760 }
761
762 #[test]
765 fn test_search_match_debug() {
766 let m = SearchMatch {
767 file_path: "test.rs".to_string(),
768 line_number: 42,
769 line_content: "fn test()".to_string(),
770 context_before: vec!["// comment".to_string()],
771 context_after: vec!["}".to_string()],
772 };
773 let debug_str = format!("{:?}", m);
774 assert!(debug_str.contains("test.rs"));
775 assert!(debug_str.contains("42"));
776 }
777
778 #[tokio::test]
781 async fn test_content_search() {
782 let temp_dir = TempDir::new().unwrap();
783 fs::write(
784 temp_dir.path().join("test1.rs"),
785 "fn main() {\n println!(\"Hello\");\n}",
786 )
787 .unwrap();
788 fs::write(
789 temp_dir.path().join("test2.rs"),
790 "fn helper() {\n println!(\"World\");\n}",
791 )
792 .unwrap();
793
794 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
795 let input = SearchInput {
796 root_path: PathBuf::from("."),
797 pattern: "println".to_string(),
798 search_type: "content".to_string(),
799 file_pattern: Some("*.rs".to_string()),
800 ignore_case: true,
801 max_results: 100,
802 include_hidden: false,
803 context_lines: 0,
804 literal_search: false,
805 };
806
807 let result = tool.execute(input).await.unwrap();
808 assert!(result.as_text().contains("test1.rs"));
809 assert!(result.as_text().contains("test2.rs"));
810 assert!(result.as_text().contains("println"));
811 }
812
813 #[tokio::test]
814 async fn test_filename_search() {
815 let temp_dir = TempDir::new().unwrap();
816 fs::write(temp_dir.path().join("test1.rs"), "").unwrap();
817 fs::write(temp_dir.path().join("test2.rs"), "").unwrap();
818 fs::write(temp_dir.path().join("readme.md"), "").unwrap();
819
820 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
821 let input = SearchInput {
822 root_path: PathBuf::from("."),
823 pattern: r"\.rs$".to_string(),
824 search_type: "files".to_string(),
825 file_pattern: None,
826 ignore_case: true,
827 max_results: 100,
828 include_hidden: false,
829 context_lines: 0,
830 literal_search: false,
831 };
832
833 let result = tool.execute(input).await.unwrap();
834 assert!(result.as_text().contains("test1.rs"));
835 assert!(result.as_text().contains("test2.rs"));
836 assert!(!result.as_text().contains("readme.md"));
837 }
838
839 #[tokio::test]
840 async fn test_context_lines() {
841 let temp_dir = TempDir::new().unwrap();
842 fs::write(
843 temp_dir.path().join("test.txt"),
844 "line 1\nline 2\ntarget line\nline 4\nline 5",
845 )
846 .unwrap();
847
848 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
849 let input = SearchInput {
850 root_path: PathBuf::from("."),
851 pattern: "target".to_string(),
852 search_type: "content".to_string(),
853 file_pattern: None,
854 ignore_case: true,
855 max_results: 100,
856 include_hidden: false,
857 context_lines: 1,
858 literal_search: true,
859 };
860
861 let result = tool.execute(input).await.unwrap();
862 assert!(result.as_text().contains("line 2"));
863 assert!(result.as_text().contains("target line"));
864 assert!(result.as_text().contains("line 4"));
865 }
866
867 #[tokio::test]
870 async fn test_search_hidden_files() {
871 let temp_dir = TempDir::new().unwrap();
872 fs::write(temp_dir.path().join(".hidden"), "secret content").unwrap();
873 fs::write(temp_dir.path().join("visible.txt"), "normal content").unwrap();
874
875 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
876
877 let input = SearchInput {
879 root_path: PathBuf::from("."),
880 pattern: "content".to_string(),
881 search_type: "content".to_string(),
882 file_pattern: None,
883 ignore_case: true,
884 max_results: 100,
885 include_hidden: false,
886 context_lines: 0,
887 literal_search: true,
888 };
889
890 let result = tool.execute(input.clone()).await.unwrap();
891 let output = result.as_text();
892 assert!(output.contains("visible.txt"));
893 assert!(!output.contains(".hidden"));
894
895 let input_with_hidden = SearchInput {
897 include_hidden: true,
898 ..input
899 };
900
901 let result_with_hidden = tool.execute(input_with_hidden).await.unwrap();
902 let output_with_hidden = result_with_hidden.as_text();
903 assert!(output_with_hidden.contains(".hidden") || output_with_hidden.contains("secret"));
904 }
905
906 #[tokio::test]
907 async fn test_search_large_file() {
908 let temp_dir = TempDir::new().unwrap();
909
910 let large_content = (0..1000)
912 .map(|i| {
913 if i == 500 {
914 "NEEDLE in the haystack".to_string()
915 } else {
916 format!("Line {} with regular content", i)
917 }
918 })
919 .collect::<Vec<_>>()
920 .join("\n");
921
922 fs::write(temp_dir.path().join("large.txt"), large_content).unwrap();
923
924 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
925 let input = SearchInput {
926 root_path: PathBuf::from("."),
927 pattern: "NEEDLE".to_string(),
928 search_type: "content".to_string(),
929 file_pattern: None,
930 ignore_case: false,
931 max_results: 100,
932 include_hidden: false,
933 context_lines: 0,
934 literal_search: true,
935 };
936
937 let result = tool.execute(input).await.unwrap();
938 assert!(result.as_text().contains("NEEDLE"));
939 assert!(result.as_text().contains("large.txt"));
940 }
941
942 #[tokio::test]
943 async fn test_search_no_results() {
944 let temp_dir = TempDir::new().unwrap();
945 fs::write(temp_dir.path().join("test.txt"), "some content").unwrap();
946
947 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
948 let input = SearchInput {
949 root_path: PathBuf::from("."),
950 pattern: "NONEXISTENT_PATTERN_XYZ".to_string(),
951 search_type: "content".to_string(),
952 file_pattern: None,
953 ignore_case: true,
954 max_results: 100,
955 include_hidden: false,
956 context_lines: 0,
957 literal_search: true,
958 };
959
960 let result = tool.execute(input).await.unwrap();
961 let output = result.as_text();
962 assert!(
965 !output.contains("NONEXISTENT_PATTERN_XYZ") || output.is_empty() || output.len() < 100
966 );
967 }
968
969 #[tokio::test]
970 async fn test_search_case_sensitive() {
971 let temp_dir = TempDir::new().unwrap();
972 fs::write(temp_dir.path().join("test.txt"), "Hello HELLO hello").unwrap();
973
974 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
975
976 let input = SearchInput {
978 root_path: PathBuf::from("."),
979 pattern: "HELLO".to_string(),
980 search_type: "content".to_string(),
981 file_pattern: None,
982 ignore_case: false,
983 max_results: 100,
984 include_hidden: false,
985 context_lines: 0,
986 literal_search: true,
987 };
988
989 let result = tool.execute(input).await.unwrap();
990 assert!(result.as_text().contains("HELLO"));
991 }
992
993 #[tokio::test]
994 async fn test_search_max_results_limit() {
995 let temp_dir = TempDir::new().unwrap();
996
997 for i in 0..10 {
999 fs::write(
1000 temp_dir.path().join(format!("file{}.txt", i)),
1001 "target content",
1002 )
1003 .unwrap();
1004 }
1005
1006 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
1007 let input = SearchInput {
1008 root_path: PathBuf::from("."),
1009 pattern: "target".to_string(),
1010 search_type: "content".to_string(),
1011 file_pattern: None,
1012 ignore_case: true,
1013 max_results: 3, include_hidden: false,
1015 context_lines: 0,
1016 literal_search: true,
1017 };
1018
1019 let result = tool.execute(input).await.unwrap();
1020 let output = result.as_text();
1021 assert!(output.contains("target") || output.contains("file"));
1023 }
1024
1025 #[tokio::test]
1026 async fn test_search_utf8_content() {
1027 let temp_dir = TempDir::new().unwrap();
1028 fs::write(
1029 temp_dir.path().join("utf8.txt"),
1030 "Hello 世界! Ümläüts: äöü 🎵",
1031 )
1032 .unwrap();
1033
1034 let tool = SearchTool::with_base_path(temp_dir.path().to_path_buf());
1035 let input = SearchInput {
1036 root_path: PathBuf::from("."),
1037 pattern: "世界".to_string(),
1038 search_type: "content".to_string(),
1039 file_pattern: None,
1040 ignore_case: false,
1041 max_results: 100,
1042 include_hidden: false,
1043 context_lines: 0,
1044 literal_search: true,
1045 };
1046
1047 let result = tool.execute(input).await.unwrap();
1048 assert!(result.as_text().contains("世界"));
1049 }
1050}