Skip to main content

aster/tools/search/
mod.rs

1//! Search Tools Module
2//!
3//! This module provides search tools including:
4//! - GlobTool: Find files using glob patterns
5//! - GrepTool: Search file contents using regex patterns
6//! - ripgrep: Enhanced ripgrep integration with vendored binary support
7//!
8//! Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8
9
10pub mod glob;
11pub mod grep;
12pub mod ripgrep;
13
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::time::SystemTime;
17
18// Re-export tools
19pub use glob::GlobTool;
20pub use grep::{GrepOutputMode, GrepTool};
21
22/// Maximum number of search results to return by default
23pub const DEFAULT_MAX_RESULTS: usize = 100;
24
25/// Maximum number of context lines for grep
26pub const DEFAULT_MAX_CONTEXT_LINES: usize = 5;
27
28/// Maximum total output size in bytes
29pub const MAX_OUTPUT_SIZE: usize = 100_000;
30
31/// A single search result entry
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SearchResult {
34    /// Path to the matched file
35    pub path: PathBuf,
36
37    /// Line number (1-indexed) where match was found (for grep)
38    pub line_number: Option<usize>,
39
40    /// The matched line content (for grep)
41    pub line_content: Option<String>,
42
43    /// Context lines before the match
44    pub context_before: Vec<String>,
45
46    /// Context lines after the match
47    pub context_after: Vec<String>,
48
49    /// File modification time (for glob)
50    pub mtime: Option<SystemTime>,
51
52    /// File size in bytes (for glob)
53    pub size: Option<u64>,
54
55    /// Match count (for count mode)
56    pub match_count: Option<usize>,
57}
58
59impl SearchResult {
60    /// Create a new SearchResult for a file match (glob)
61    pub fn file_match(path: PathBuf) -> Self {
62        Self {
63            path,
64            line_number: None,
65            line_content: None,
66            context_before: Vec::new(),
67            context_after: Vec::new(),
68            mtime: None,
69            size: None,
70            match_count: None,
71        }
72    }
73
74    /// Create a new SearchResult for a content match (grep)
75    pub fn content_match(path: PathBuf, line_number: usize, line_content: String) -> Self {
76        Self {
77            path,
78            line_number: Some(line_number),
79            line_content: Some(line_content),
80            context_before: Vec::new(),
81            context_after: Vec::new(),
82            mtime: None,
83            size: None,
84            match_count: None,
85        }
86    }
87
88    /// Create a new SearchResult for count mode
89    pub fn count_match(path: PathBuf, count: usize) -> Self {
90        Self {
91            path,
92            line_number: None,
93            line_content: None,
94            context_before: Vec::new(),
95            context_after: Vec::new(),
96            mtime: None,
97            size: None,
98            match_count: Some(count),
99        }
100    }
101
102    /// Set file metadata
103    pub fn with_metadata(mut self, mtime: SystemTime, size: u64) -> Self {
104        self.mtime = Some(mtime);
105        self.size = Some(size);
106        self
107    }
108
109    /// Set context lines
110    pub fn with_context(mut self, before: Vec<String>, after: Vec<String>) -> Self {
111        self.context_before = before;
112        self.context_after = after;
113        self
114    }
115}
116
117/// Format search results for output
118pub fn format_search_results(results: &[SearchResult], truncated: bool) -> String {
119    let mut output = String::new();
120
121    for result in results {
122        if let Some(line_number) = result.line_number {
123            // Grep-style output
124            output.push_str(&format!(
125                "{}:{}:{}\n",
126                result.path.display(),
127                line_number,
128                result.line_content.as_deref().unwrap_or("")
129            ));
130        } else if let Some(count) = result.match_count {
131            // Count mode output
132            output.push_str(&format!("{}:{}\n", result.path.display(), count));
133        } else {
134            // Glob-style output (just the path)
135            output.push_str(&format!("{}\n", result.path.display()));
136        }
137    }
138
139    if truncated {
140        output.push_str(&format!(
141            "\n[Results truncated. Showing {} of more results.]\n",
142            results.len()
143        ));
144    }
145
146    output
147}
148
149/// Truncate results to fit within size limit
150pub fn truncate_results(
151    results: Vec<SearchResult>,
152    max_results: usize,
153) -> (Vec<SearchResult>, bool) {
154    if results.len() > max_results {
155        (results.into_iter().take(max_results).collect(), true)
156    } else {
157        (results, false)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_search_result_file_match() {
167        let result = SearchResult::file_match(PathBuf::from("/tmp/test.txt"));
168        assert_eq!(result.path, PathBuf::from("/tmp/test.txt"));
169        assert!(result.line_number.is_none());
170        assert!(result.line_content.is_none());
171    }
172
173    #[test]
174    fn test_search_result_content_match() {
175        let result = SearchResult::content_match(
176            PathBuf::from("/tmp/test.txt"),
177            42,
178            "Hello, World!".to_string(),
179        );
180        assert_eq!(result.path, PathBuf::from("/tmp/test.txt"));
181        assert_eq!(result.line_number, Some(42));
182        assert_eq!(result.line_content, Some("Hello, World!".to_string()));
183    }
184
185    #[test]
186    fn test_search_result_count_match() {
187        let result = SearchResult::count_match(PathBuf::from("/tmp/test.txt"), 10);
188        assert_eq!(result.path, PathBuf::from("/tmp/test.txt"));
189        assert_eq!(result.match_count, Some(10));
190    }
191
192    #[test]
193    fn test_search_result_with_metadata() {
194        let mtime = SystemTime::now();
195        let result =
196            SearchResult::file_match(PathBuf::from("/tmp/test.txt")).with_metadata(mtime, 1024);
197        assert_eq!(result.mtime, Some(mtime));
198        assert_eq!(result.size, Some(1024));
199    }
200
201    #[test]
202    fn test_search_result_with_context() {
203        let result = SearchResult::content_match(
204            PathBuf::from("/tmp/test.txt"),
205            5,
206            "match line".to_string(),
207        )
208        .with_context(
209            vec!["line 3".to_string(), "line 4".to_string()],
210            vec!["line 6".to_string(), "line 7".to_string()],
211        );
212        assert_eq!(result.context_before.len(), 2);
213        assert_eq!(result.context_after.len(), 2);
214    }
215
216    #[test]
217    fn test_format_search_results_grep() {
218        let results = vec![
219            SearchResult::content_match(PathBuf::from("/tmp/test.txt"), 10, "Hello".to_string()),
220            SearchResult::content_match(PathBuf::from("/tmp/test.txt"), 20, "World".to_string()),
221        ];
222
223        let output = format_search_results(&results, false);
224        assert!(output.contains("/tmp/test.txt:10:Hello"));
225        assert!(output.contains("/tmp/test.txt:20:World"));
226    }
227
228    #[test]
229    fn test_format_search_results_glob() {
230        let results = vec![
231            SearchResult::file_match(PathBuf::from("/tmp/a.txt")),
232            SearchResult::file_match(PathBuf::from("/tmp/b.txt")),
233        ];
234
235        let output = format_search_results(&results, false);
236        assert!(output.contains("/tmp/a.txt"));
237        assert!(output.contains("/tmp/b.txt"));
238    }
239
240    #[test]
241    fn test_format_search_results_truncated() {
242        let results = vec![SearchResult::file_match(PathBuf::from("/tmp/test.txt"))];
243        let output = format_search_results(&results, true);
244        assert!(output.contains("[Results truncated"));
245    }
246
247    #[test]
248    fn test_truncate_results() {
249        let results: Vec<SearchResult> = (0..10)
250            .map(|i| SearchResult::file_match(PathBuf::from(format!("/tmp/test{}.txt", i))))
251            .collect();
252
253        let (truncated, was_truncated) = truncate_results(results.clone(), 5);
254        assert_eq!(truncated.len(), 5);
255        assert!(was_truncated);
256
257        let (not_truncated, was_truncated) = truncate_results(results, 20);
258        assert_eq!(not_truncated.len(), 10);
259        assert!(!was_truncated);
260    }
261}