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#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Match {
12 pub file: PathBuf,
14 pub line: usize,
16 pub content: String,
18}
19
20pub struct TextSearcher {
22 respect_gitignore: bool,
24 case_sensitive: bool,
26 base_dir: PathBuf,
28}
29
30impl TextSearcher {
31 pub fn new(base_dir: PathBuf) -> Self {
33 Self {
34 respect_gitignore: true,
35 case_sensitive: false,
36 base_dir,
37 }
38 }
39
40 pub fn respect_gitignore(mut self, value: bool) -> Self {
42 self.respect_gitignore = value;
43 self
44 }
45
46 pub fn case_sensitive(mut self, value: bool) -> Self {
48 self.case_sensitive = value;
49 self
50 }
51
52 pub fn search(&self, text: &str) -> Result<Vec<Match>> {
60 let matcher = RegexMatcherBuilder::new()
62 .case_insensitive(!self.case_sensitive)
63 .fixed_strings(true) .build(text)
65 .map_err(|e| SearchError::Generic(format!("Failed to build matcher: {}", e)))?;
66
67 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) .build();
74
75 let matches = Arc::new(Mutex::new(Vec::new()));
77
78 for entry in walker {
80 let entry = match entry {
81 Ok(e) => e,
82 Err(_) => continue, };
84
85 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
87 continue;
88 }
89
90 let path = entry.path();
91
92 let matches_clone = Arc::clone(&matches);
94 let path_buf = path.to_path_buf();
95
96 let mut searcher = SearcherBuilder::new().line_number(true).build();
98
99 let result = searcher.search_path(
101 &matcher,
102 path,
103 UTF8(|line_num, line_content| {
104 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) }),
113 );
114
115 if result.is_err() {
117 continue;
118 }
119 }
120
121 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); }
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); 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 fs::create_dir(temp_dir.path().join(".git")).unwrap();
223
224 fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
226
227 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 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 fs::create_dir(temp_dir.path().join(".git")).unwrap();
245
246 fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
248
249 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 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 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}