cs/search/
text_search.rs

1use crate::error::{Result, SearchError};
2use grep_regex::RegexMatcherBuilder;
3use grep_searcher::sinks::UTF8;
4use grep_searcher::SearcherBuilder;
5use ignore::WalkBuilder;
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8
9/// Represents a single match from a text search
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Match {
12    /// File path where the match was found
13    pub file: PathBuf,
14    /// Line number (1-indexed)
15    pub line: usize,
16    /// Content of the matching line
17    pub content: String,
18}
19
20/// Text searcher that uses ripgrep as a library for fast text searching
21pub struct TextSearcher {
22    /// Whether to respect .gitignore files
23    respect_gitignore: bool,
24    /// Whether search is case-sensitive
25    case_sensitive: bool,
26    /// The base directory to search in
27    base_dir: PathBuf,
28}
29
30impl TextSearcher {
31    /// Create a new TextSearcher with default settings
32    pub fn new(base_dir: PathBuf) -> Self {
33        Self {
34            respect_gitignore: true,
35            case_sensitive: false,
36            base_dir,
37        }
38    }
39
40    /// Set whether to respect .gitignore files (default: true)
41    pub fn respect_gitignore(mut self, value: bool) -> Self {
42        self.respect_gitignore = value;
43        self
44    }
45
46    /// Set whether search is case-sensitive (default: false)
47    pub fn case_sensitive(mut self, value: bool) -> Self {
48        self.case_sensitive = value;
49        self
50    }
51
52    /// Search for text and return all matches
53    ///
54    /// # Arguments
55    /// * `text` - The text to search for
56    ///
57    /// # Returns
58    /// A vector of Match structs containing file path, line number, and content
59    pub fn search(&self, text: &str) -> Result<Vec<Match>> {
60        // Build the regex matcher with fixed string (literal) matching
61        let matcher = RegexMatcherBuilder::new()
62            .case_insensitive(!self.case_sensitive)
63            .fixed_strings(true) // Literal string matching, not regex
64            .build(text)
65            .map_err(|e| SearchError::Generic(format!("Failed to build matcher: {}", e)))?;
66
67        // Build the file walker with .gitignore support
68        let walker = WalkBuilder::new(&self.base_dir)
69            .git_ignore(self.respect_gitignore)
70            .git_global(self.respect_gitignore)
71            .git_exclude(self.respect_gitignore)
72            .hidden(false) // Don't skip hidden files by default
73            .build();
74
75        // Shared vector to collect matches from all threads
76        let matches = Arc::new(Mutex::new(Vec::new()));
77
78        // Search each file
79        for entry in walker {
80            let entry = match entry {
81                Ok(e) => e,
82                Err(_) => continue, // Skip entries we can't read
83            };
84
85            // Skip directories
86            if entry.file_type().is_none_or(|ft| ft.is_dir()) {
87                continue;
88            }
89
90            let path = entry.path();
91
92            // Clone Arc for the closure
93            let matches_clone = Arc::clone(&matches);
94            let path_buf = path.to_path_buf();
95
96            // Build searcher
97            let mut searcher = SearcherBuilder::new().line_number(true).build();
98
99            // Search the file
100            let result = searcher.search_path(
101                &matcher,
102                path,
103                UTF8(|line_num, line_content| {
104                    // Collect the match
105                    let mut matches = matches_clone.lock().unwrap();
106                    matches.push(Match {
107                        file: path_buf.clone(),
108                        line: line_num as usize,
109                        content: line_content.trim_end().to_string(),
110                    });
111                    Ok(true) // Continue searching
112                }),
113            );
114
115            // Ignore search errors for individual files
116            if result.is_err() {
117                continue;
118            }
119        }
120
121        // Extract matches from Arc<Mutex<Vec>>
122        let matches = match Arc::try_unwrap(matches) {
123            Ok(mutex) => mutex.into_inner().unwrap(),
124            Err(arc) => arc.lock().unwrap().clone(),
125        };
126
127        Ok(matches)
128    }
129}
130
131impl Default for TextSearcher {
132    fn default() -> Self {
133        Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::fs;
141    use tempfile::TempDir;
142
143    #[test]
144    fn test_basic_search() {
145        let temp_dir = TempDir::new().unwrap();
146        fs::write(
147            temp_dir.path().join("test.txt"),
148            "hello world\nfoo bar\nhello again",
149        )
150        .unwrap();
151
152        let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
153        let matches = searcher.search("hello").unwrap();
154
155        assert_eq!(matches.len(), 2);
156        assert_eq!(matches[0].line, 1);
157        assert_eq!(matches[0].content, "hello world");
158        assert_eq!(matches[1].line, 3);
159        assert_eq!(matches[1].content, "hello again");
160    }
161
162    #[test]
163    fn test_case_insensitive_default() {
164        let temp_dir = TempDir::new().unwrap();
165        fs::write(
166            temp_dir.path().join("test.txt"),
167            "Hello World\nHELLO\nhello",
168        )
169        .unwrap();
170
171        let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
172        let matches = searcher.search("hello").unwrap();
173
174        assert_eq!(matches.len(), 3); // Should match all variations
175    }
176
177    #[test]
178    fn test_case_sensitive() {
179        let temp_dir = TempDir::new().unwrap();
180        fs::write(
181            temp_dir.path().join("test.txt"),
182            "Hello World\nHELLO\nhello",
183        )
184        .unwrap();
185
186        let searcher = TextSearcher::new(temp_dir.path().to_path_buf()).case_sensitive(true);
187        let matches = searcher.search("hello").unwrap();
188
189        assert_eq!(matches.len(), 1); // Should only match exact case
190        assert_eq!(matches[0].content, "hello");
191    }
192
193    #[test]
194    fn test_no_matches() {
195        let temp_dir = TempDir::new().unwrap();
196        fs::write(temp_dir.path().join("test.txt"), "foo bar baz").unwrap();
197
198        let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
199        let matches = searcher.search("notfound").unwrap();
200
201        assert_eq!(matches.len(), 0);
202    }
203
204    #[test]
205    fn test_multiple_files() {
206        let temp_dir = TempDir::new().unwrap();
207        fs::write(temp_dir.path().join("file1.txt"), "target line 1").unwrap();
208        fs::write(temp_dir.path().join("file2.txt"), "target line 2").unwrap();
209        fs::write(temp_dir.path().join("file3.txt"), "other content").unwrap();
210
211        let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
212        let matches = searcher.search("target").unwrap();
213
214        assert_eq!(matches.len(), 2);
215    }
216
217    #[test]
218    fn test_gitignore_respected() {
219        let temp_dir = TempDir::new().unwrap();
220
221        // Initialize git repository (required for .gitignore to work)
222        fs::create_dir(temp_dir.path().join(".git")).unwrap();
223
224        // Create .gitignore
225        fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
226
227        // Create files
228        fs::write(temp_dir.path().join("ignored.txt"), "target content").unwrap();
229        fs::write(temp_dir.path().join("tracked.txt"), "target content").unwrap();
230
231        let searcher = TextSearcher::new(temp_dir.path().to_path_buf()).respect_gitignore(true);
232        let matches = searcher.search("target").unwrap();
233
234        // Should only find in tracked.txt
235        assert_eq!(matches.len(), 1);
236        assert!(matches[0].file.ends_with("tracked.txt"));
237    }
238
239    #[test]
240    fn test_gitignore_disabled() {
241        let temp_dir = TempDir::new().unwrap();
242
243        // Initialize git repository
244        fs::create_dir(temp_dir.path().join(".git")).unwrap();
245
246        // Create .gitignore
247        fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
248
249        // Create files
250        fs::write(temp_dir.path().join("ignored.txt"), "target content").unwrap();
251        fs::write(temp_dir.path().join("tracked.txt"), "target content").unwrap();
252
253        let searcher = TextSearcher::new(temp_dir.path().to_path_buf()).respect_gitignore(false);
254        let matches = searcher.search("target").unwrap();
255
256        // Should find in both files
257        assert_eq!(matches.len(), 2);
258    }
259
260    #[test]
261    fn test_builder_pattern() {
262        let searcher = TextSearcher::new(std::env::current_dir().unwrap())
263            .case_sensitive(true)
264            .respect_gitignore(false);
265
266        assert_eq!(searcher.case_sensitive, true);
267        assert_eq!(searcher.respect_gitignore, false);
268    }
269
270    #[test]
271    fn test_default() {
272        let searcher = TextSearcher::default();
273
274        assert_eq!(searcher.case_sensitive, false);
275        assert_eq!(searcher.respect_gitignore, true);
276    }
277
278    #[test]
279    fn test_special_characters() {
280        let temp_dir = TempDir::new().unwrap();
281        fs::write(
282            temp_dir.path().join("test.txt"),
283            "price: $19.99\nurl: http://example.com",
284        )
285        .unwrap();
286
287        let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
288
289        // Test with special regex characters (should be treated as literals)
290        let matches = searcher.search("$19.99").unwrap();
291        assert_eq!(matches.len(), 1);
292
293        let matches = searcher.search("http://").unwrap();
294        assert_eq!(matches.len(), 1);
295    }
296
297    #[test]
298    fn test_line_numbers_accurate() {
299        let temp_dir = TempDir::new().unwrap();
300        let content = "line 1\nline 2\ntarget line 3\nline 4\ntarget line 5\nline 6";
301        fs::write(temp_dir.path().join("test.txt"), content).unwrap();
302
303        let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
304        let matches = searcher.search("target").unwrap();
305
306        assert_eq!(matches.len(), 2);
307        assert_eq!(matches[0].line, 3);
308        assert_eq!(matches[1].line, 5);
309    }
310}