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::mpsc;
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 (tx, rx) = mpsc::channel();
69
70 WalkBuilder::new(&self.base_dir)
72 .git_ignore(self.respect_gitignore)
73 .git_global(self.respect_gitignore)
74 .git_exclude(self.respect_gitignore)
75 .hidden(false) .build_parallel()
77 .run(|| {
78 let tx = tx.clone();
80 let matcher = matcher.clone();
81
82 Box::new(move |entry| {
83 use ignore::WalkState;
84
85 let entry = match entry {
86 Ok(e) => e,
87 Err(_) => return WalkState::Continue,
88 };
89
90 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
92 return WalkState::Continue;
93 }
94
95 let path = entry.path();
96 let path_buf = path.to_path_buf();
97
98 let mut file_matches = Vec::new();
100
101 let mut searcher = SearcherBuilder::new().line_number(true).build();
103
104 let result = searcher.search_path(
106 &matcher,
107 path,
108 UTF8(|line_num, line_content| {
109 file_matches.push(Match {
110 file: path_buf.clone(),
111 line: line_num as usize,
112 content: line_content.trim_end().to_string(),
113 });
114 Ok(true) }),
116 );
117
118 if result.is_ok() && !file_matches.is_empty() {
120 let _ = tx.send(file_matches);
121 }
122
123 WalkState::Continue
124 })
125 });
126
127 drop(tx);
129
130 let mut all_matches = Vec::new();
132 for file_matches in rx {
133 all_matches.extend(file_matches);
134 }
135
136 Ok(all_matches)
137 }
138}
139
140impl Default for TextSearcher {
141 fn default() -> Self {
142 Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use std::fs;
150 use tempfile::TempDir;
151
152 #[test]
153 fn test_basic_search() {
154 let temp_dir = TempDir::new().unwrap();
155 fs::write(
156 temp_dir.path().join("test.txt"),
157 "hello world\nfoo bar\nhello again",
158 )
159 .unwrap();
160
161 let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
162 let matches = searcher.search("hello").unwrap();
163
164 assert_eq!(matches.len(), 2);
165 assert_eq!(matches[0].line, 1);
166 assert_eq!(matches[0].content, "hello world");
167 assert_eq!(matches[1].line, 3);
168 assert_eq!(matches[1].content, "hello again");
169 }
170
171 #[test]
172 fn test_case_insensitive_default() {
173 let temp_dir = TempDir::new().unwrap();
174 fs::write(
175 temp_dir.path().join("test.txt"),
176 "Hello World\nHELLO\nhello",
177 )
178 .unwrap();
179
180 let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
181 let matches = searcher.search("hello").unwrap();
182
183 assert_eq!(matches.len(), 3); }
185
186 #[test]
187 fn test_case_sensitive() {
188 let temp_dir = TempDir::new().unwrap();
189 fs::write(
190 temp_dir.path().join("test.txt"),
191 "Hello World\nHELLO\nhello",
192 )
193 .unwrap();
194
195 let searcher = TextSearcher::new(temp_dir.path().to_path_buf()).case_sensitive(true);
196 let matches = searcher.search("hello").unwrap();
197
198 assert_eq!(matches.len(), 1); assert_eq!(matches[0].content, "hello");
200 }
201
202 #[test]
203 fn test_no_matches() {
204 let temp_dir = TempDir::new().unwrap();
205 fs::write(temp_dir.path().join("test.txt"), "foo bar baz").unwrap();
206
207 let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
208 let matches = searcher.search("notfound").unwrap();
209
210 assert_eq!(matches.len(), 0);
211 }
212
213 #[test]
214 fn test_multiple_files() {
215 let temp_dir = TempDir::new().unwrap();
216 fs::write(temp_dir.path().join("file1.txt"), "target line 1").unwrap();
217 fs::write(temp_dir.path().join("file2.txt"), "target line 2").unwrap();
218 fs::write(temp_dir.path().join("file3.txt"), "other content").unwrap();
219
220 let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
221 let matches = searcher.search("target").unwrap();
222
223 assert_eq!(matches.len(), 2);
224 }
225
226 #[test]
227 fn test_gitignore_respected() {
228 let temp_dir = TempDir::new().unwrap();
229
230 fs::create_dir(temp_dir.path().join(".git")).unwrap();
232
233 fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
235
236 fs::write(temp_dir.path().join("ignored.txt"), "target content").unwrap();
238 fs::write(temp_dir.path().join("tracked.txt"), "target content").unwrap();
239
240 let searcher = TextSearcher::new(temp_dir.path().to_path_buf()).respect_gitignore(true);
241 let matches = searcher.search("target").unwrap();
242
243 assert_eq!(matches.len(), 1);
245 assert!(matches[0].file.ends_with("tracked.txt"));
246 }
247
248 #[test]
249 fn test_gitignore_disabled() {
250 let temp_dir = TempDir::new().unwrap();
251
252 fs::create_dir(temp_dir.path().join(".git")).unwrap();
254
255 fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
257
258 fs::write(temp_dir.path().join("ignored.txt"), "target content").unwrap();
260 fs::write(temp_dir.path().join("tracked.txt"), "target content").unwrap();
261
262 let searcher = TextSearcher::new(temp_dir.path().to_path_buf()).respect_gitignore(false);
263 let matches = searcher.search("target").unwrap();
264
265 assert_eq!(matches.len(), 2);
267 }
268
269 #[test]
270 fn test_builder_pattern() {
271 let searcher = TextSearcher::new(std::env::current_dir().unwrap())
272 .case_sensitive(true)
273 .respect_gitignore(false);
274
275 assert_eq!(searcher.case_sensitive, true);
276 assert_eq!(searcher.respect_gitignore, false);
277 }
278
279 #[test]
280 fn test_default() {
281 let searcher = TextSearcher::default();
282
283 assert_eq!(searcher.case_sensitive, false);
284 assert_eq!(searcher.respect_gitignore, true);
285 }
286
287 #[test]
288 fn test_special_characters() {
289 let temp_dir = TempDir::new().unwrap();
290 fs::write(
291 temp_dir.path().join("test.txt"),
292 "price: $19.99\nurl: http://example.com",
293 )
294 .unwrap();
295
296 let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
297
298 let matches = searcher.search("$19.99").unwrap();
300 assert_eq!(matches.len(), 1);
301
302 let matches = searcher.search("http://").unwrap();
303 assert_eq!(matches.len(), 1);
304 }
305
306 #[test]
307 fn test_line_numbers_accurate() {
308 let temp_dir = TempDir::new().unwrap();
309 let content = "line 1\nline 2\ntarget line 3\nline 4\ntarget line 5\nline 6";
310 fs::write(temp_dir.path().join("test.txt"), content).unwrap();
311
312 let searcher = TextSearcher::new(temp_dir.path().to_path_buf());
313 let matches = searcher.search("target").unwrap();
314
315 assert_eq!(matches.len(), 2);
316 assert_eq!(matches[0].line, 3);
317 assert_eq!(matches[1].line, 5);
318 }
319}