Skip to main content

mixtape_tools/search/
search_tool.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use ignore::WalkBuilder;
4use regex::Regex;
5use std::fs;
6use std::path::PathBuf;
7
8/// Search result entry
9#[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/// Input for content search
19#[derive(Debug, Clone, Deserialize, JsonSchema)]
20pub struct SearchInput {
21    /// Root directory or file to search in
22    pub root_path: PathBuf,
23
24    /// Search pattern (regex or literal string)
25    pub pattern: String,
26
27    /// Search type: "files" for filename search, "content" for text search
28    #[serde(default = "default_search_type")]
29    pub search_type: String,
30
31    /// Optional glob pattern to filter files (e.g., "*.rs|*.toml")
32    #[serde(default)]
33    pub file_pattern: Option<String>,
34
35    /// Case-insensitive search (default: true)
36    #[serde(default = "default_ignore_case")]
37    pub ignore_case: bool,
38
39    /// Maximum number of results to return (default: 100)
40    #[serde(default = "default_max_results")]
41    pub max_results: usize,
42
43    /// Include hidden files and directories (default: false)
44    #[serde(default)]
45    pub include_hidden: bool,
46
47    /// Lines of context to show around matches (default: 0)
48    #[serde(default)]
49    pub context_lines: usize,
50
51    /// Force literal string matching instead of regex (default: false)
52    #[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
68/// Tool for searching file contents using ripgrep-like functionality
69pub 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    /// Create a new SearchTool using the current working directory as the base path
81    pub fn new() -> Self {
82        Self {
83            base_path: std::env::current_dir().expect("Failed to get current working directory"),
84        }
85    }
86
87    /// Create a SearchTool with a custom base directory
88    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, // 1-indexed
130                    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        // Build regex pattern
193        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        // Parse file pattern if provided
207        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                // Filename search
219                let matches = self.search_filenames(
220                    &root_path,
221                    &regex_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                // Content search
245                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                    // Skip directories
262                    if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
263                        continue;
264                    }
265
266                    // Check file pattern if specified
267                    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                    // Search file contents
276                    match self.search_file_contents(
277                        &entry.path().to_path_buf(),
278                        &regex_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                            // Skip files that can't be read (binary, permissions, etc.)
291                            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                        // Add context before
313                        for ctx in &m.context_before {
314                            result.push_str(&format!("  | {}\n", ctx));
315                        }
316
317                        // Add matching line
318                        result.push_str(&format!("  > {}\n", m.line_content));
319
320                        // Add context after
321                        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    // ==================== Default and constructor tests ====================
535
536    #[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    // ==================== Default value function tests ====================
556
557    #[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    // ==================== format_output_plain tests ====================
573
574    #[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        // Check for arrow transformation
613        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    // ==================== format_output_ansi tests ====================
628
629    #[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        // Should be dimmed
636        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")); // dimmed
647    }
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        // Header should be bold
656        assert!(formatted.contains("\x1b[1m"));
657        // Files should be magenta
658        assert!(formatted.contains("\x1b[35m"));
659    }
660
661    #[test]
662    fn test_format_output_ansi_content_search() {
663        let tool = SearchTool::new();
664        // Use format that triggers line number extraction (need colon after number)
665        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        // Header should be bold
670        assert!(formatted.contains("\x1b[1m"));
671        // File path should be magenta
672        assert!(formatted.contains("\x1b[35m"));
673        // Line number should be green (only when content follows the line number)
674        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        // Match indicator (arrow) should be yellow
685        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        // Context lines should be dimmed
697        assert!(formatted.contains("\x1b[2m"));
698    }
699
700    // ==================== format_output_markdown tests ====================
701
702    #[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        // Should be italicized
709        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        // Header should be bold
728        assert!(formatted.contains("**Found 2 file(s)"));
729        // Files should be in list with code formatting
730        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        // Header should be bold
742        assert!(formatted.contains("**Found 1 match"));
743        // File should be a heading with code formatting
744        assert!(formatted.contains("### `src/main.rs`"));
745        // Code block should be present
746        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        // Should have both opening and closing code blocks
757        let open_count = formatted.matches("```").count();
758        // Should have at least one pair (open + close)
759        assert!(open_count >= 2 || open_count == 0);
760    }
761
762    // ==================== SearchMatch struct tests ====================
763
764    #[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    // ==================== Integration tests ====================
779
780    #[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    // ===== Edge Case Tests =====
868
869    #[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        // Search without include_hidden
878        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        // Search with include_hidden
896        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        // Create a large file with 1000 lines
911        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        // When no results, output should be minimal or indicate no matches
963        // The exact format may vary, so just ensure we got a result without panicking
964        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        // Case-sensitive search
977        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        // Create multiple files with matches
998        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, // Limit to 3 results
1014            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        // Should have limited results (exact count may vary based on implementation)
1022        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}