1use std::path::{Path, PathBuf};
115
116use crate::error::Result;
117
118pub trait SearchCapable: super::FileSystem {
128 fn search_provider(&self, path: &Path) -> Option<Box<dyn SearchProvider>>;
131}
132
133pub trait SearchProvider: Send + Sync {
138 fn search(&self, query: &SearchQuery) -> Result<SearchResults>;
140
141 fn capabilities(&self) -> SearchCapabilities;
143}
144
145#[derive(Debug, Clone)]
147pub struct SearchQuery {
148 pub pattern: String,
150 pub is_regex: bool,
152 pub case_insensitive: bool,
154 pub root: PathBuf,
156 pub glob_filter: Option<String>,
158 pub max_results: Option<usize>,
160}
161
162#[derive(Debug, Clone, Default)]
164pub struct SearchResults {
165 pub matches: Vec<SearchMatch>,
167 pub truncated: bool,
169}
170
171#[derive(Debug, Clone)]
173pub struct SearchMatch {
174 pub path: PathBuf,
176 pub line_number: usize,
178 pub line_content: String,
180}
181
182#[derive(Debug, Clone, Copy, Default)]
184pub struct SearchCapabilities {
185 pub regex: bool,
187 pub glob_filter: bool,
189 pub content_search: bool,
191 pub filename_search: bool,
193}
194
195#[cfg(test)]
196#[allow(clippy::unwrap_used)]
197mod tests {
198 use super::*;
199 use crate::fs::{FileSystem, FileSystemExt, InMemoryFs};
200
201 struct MockSearchFs {
203 inner: InMemoryFs,
204 }
205
206 impl MockSearchFs {
207 fn new() -> Self {
208 Self {
209 inner: InMemoryFs::new(),
210 }
211 }
212 }
213
214 #[async_trait::async_trait]
215 impl FileSystemExt for MockSearchFs {}
216
217 #[async_trait::async_trait]
218 impl FileSystem for MockSearchFs {
219 async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
220 self.inner.read_file(path).await
221 }
222 async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
223 self.inner.write_file(path, content).await
224 }
225 async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
226 self.inner.append_file(path, content).await
227 }
228 async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
229 self.inner.mkdir(path, recursive).await
230 }
231 async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
232 self.inner.remove(path, recursive).await
233 }
234 async fn stat(&self, path: &Path) -> Result<crate::fs::Metadata> {
235 self.inner.stat(path).await
236 }
237 async fn read_dir(&self, path: &Path) -> Result<Vec<crate::fs::DirEntry>> {
238 self.inner.read_dir(path).await
239 }
240 async fn exists(&self, path: &Path) -> Result<bool> {
241 self.inner.exists(path).await
242 }
243 async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
244 self.inner.rename(from, to).await
245 }
246 async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
247 self.inner.copy(from, to).await
248 }
249 async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
250 self.inner.symlink(target, link).await
251 }
252 async fn read_link(&self, path: &Path) -> Result<std::path::PathBuf> {
253 self.inner.read_link(path).await
254 }
255 async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
256 self.inner.chmod(path, mode).await
257 }
258 fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
259 Some(self)
260 }
261 }
262
263 struct MockProvider {
264 results: Vec<SearchMatch>,
265 }
266
267 impl SearchProvider for MockProvider {
268 fn search(&self, _query: &SearchQuery) -> Result<SearchResults> {
269 Ok(SearchResults {
270 matches: self.results.clone(),
271 truncated: false,
272 })
273 }
274 fn capabilities(&self) -> SearchCapabilities {
275 SearchCapabilities {
276 regex: true,
277 glob_filter: true,
278 content_search: true,
279 filename_search: false,
280 }
281 }
282 }
283
284 impl SearchCapable for MockSearchFs {
285 fn search_provider(&self, _path: &Path) -> Option<Box<dyn SearchProvider>> {
286 Some(Box::new(MockProvider {
287 results: vec![SearchMatch {
288 path: PathBuf::from("/test.txt"),
289 line_number: 1,
290 line_content: "hello world".to_string(),
291 }],
292 }))
293 }
294 }
295
296 #[test]
297 fn search_query_defaults() {
298 let q = SearchQuery {
299 pattern: "test".into(),
300 is_regex: false,
301 case_insensitive: false,
302 root: PathBuf::from("/"),
303 glob_filter: None,
304 max_results: None,
305 };
306 assert_eq!(q.pattern, "test");
307 assert!(!q.is_regex);
308 }
309
310 #[test]
311 fn search_capabilities_default() {
312 let c = SearchCapabilities::default();
313 assert!(!c.regex);
314 assert!(!c.glob_filter);
315 assert!(!c.content_search);
316 assert!(!c.filename_search);
317 }
318
319 #[test]
320 fn mock_provider_returns_results() {
321 let provider = MockProvider {
322 results: vec![SearchMatch {
323 path: PathBuf::from("/a.txt"),
324 line_number: 5,
325 line_content: "found it".into(),
326 }],
327 };
328 let r = provider
329 .search(&SearchQuery {
330 pattern: "found".into(),
331 is_regex: false,
332 case_insensitive: false,
333 root: PathBuf::from("/"),
334 glob_filter: None,
335 max_results: None,
336 })
337 .unwrap();
338 assert_eq!(r.matches.len(), 1);
339 assert_eq!(r.matches[0].line_number, 5);
340 assert!(!r.truncated);
341 }
342
343 #[test]
344 fn mock_searchable_fs_provides_search() {
345 let fs = MockSearchFs::new();
346 let provider = fs.search_provider(Path::new("/")).unwrap();
347 assert!(provider.capabilities().content_search);
348 let r = provider
349 .search(&SearchQuery {
350 pattern: "hello".into(),
351 is_regex: false,
352 case_insensitive: false,
353 root: PathBuf::from("/"),
354 glob_filter: None,
355 max_results: None,
356 })
357 .unwrap();
358 assert_eq!(r.matches.len(), 1);
359 assert_eq!(r.matches[0].line_content, "hello world");
360 }
361
362 #[test]
363 fn as_search_capable_returns_provider() {
364 let fs = MockSearchFs::new();
365 let sc = fs.as_search_capable().unwrap();
366 let provider = sc.search_provider(Path::new("/")).unwrap();
367 assert!(provider.capabilities().content_search);
368 }
369
370 #[test]
371 fn non_searchable_fs_returns_none() {
372 let fs = InMemoryFs::new();
373 assert!(fs.as_search_capable().is_none());
374 }
375
376 #[test]
377 fn search_results_default_is_empty() {
378 let r = SearchResults::default();
379 assert!(r.matches.is_empty());
380 assert!(!r.truncated);
381 }
382
383 #[test]
384 fn search_match_debug() {
385 let m = SearchMatch {
386 path: PathBuf::from("/test.txt"),
387 line_number: 42,
388 line_content: "hello".into(),
389 };
390 let dbg = format!("{:?}", m);
391 assert!(dbg.contains("test.txt"));
392 assert!(dbg.contains("42"));
393 }
394
395 #[test]
398 fn search_query_with_all_options() {
399 let q = SearchQuery {
400 pattern: r"\bfoo\b".into(),
401 is_regex: true,
402 case_insensitive: true,
403 root: PathBuf::from("/src"),
404 glob_filter: Some("*.rs".into()),
405 max_results: Some(100),
406 };
407 assert!(q.is_regex);
408 assert!(q.case_insensitive);
409 assert_eq!(q.root, PathBuf::from("/src"));
410 assert_eq!(q.glob_filter.as_deref(), Some("*.rs"));
411 assert_eq!(q.max_results, Some(100));
412 }
413
414 #[test]
415 fn search_capabilities_all_enabled() {
416 let c = SearchCapabilities {
417 regex: true,
418 glob_filter: true,
419 content_search: true,
420 filename_search: true,
421 };
422 assert!(c.regex);
423 assert!(c.glob_filter);
424 assert!(c.content_search);
425 assert!(c.filename_search);
426 }
427
428 #[test]
429 fn search_results_truncated() {
430 let r = SearchResults {
431 matches: vec![SearchMatch {
432 path: PathBuf::from("/a.txt"),
433 line_number: 1,
434 line_content: "hit".into(),
435 }],
436 truncated: true,
437 };
438 assert!(r.truncated);
439 assert_eq!(r.matches.len(), 1);
440 }
441
442 #[test]
443 fn search_match_clone() {
444 let m = SearchMatch {
445 path: PathBuf::from("/b.txt"),
446 line_number: 10,
447 line_content: "cloned".into(),
448 };
449 let c = m.clone();
450 assert_eq!(c.path, m.path);
451 assert_eq!(c.line_number, m.line_number);
452 assert_eq!(c.line_content, m.line_content);
453 }
454
455 #[test]
456 fn search_results_clone() {
457 let r = SearchResults {
458 matches: vec![SearchMatch {
459 path: PathBuf::from("/c.txt"),
460 line_number: 3,
461 line_content: "data".into(),
462 }],
463 truncated: false,
464 };
465 let c = r.clone();
466 assert_eq!(c.matches.len(), 1);
467 assert_eq!(c.matches[0].line_content, "data");
468 }
469
470 #[test]
471 fn search_provider_no_content_search() {
472 struct LimitedProvider;
473 impl SearchProvider for LimitedProvider {
474 fn search(&self, _query: &SearchQuery) -> Result<SearchResults> {
475 Ok(SearchResults::default())
476 }
477 fn capabilities(&self) -> SearchCapabilities {
478 SearchCapabilities {
479 regex: false,
480 glob_filter: false,
481 content_search: false,
482 filename_search: true,
483 }
484 }
485 }
486 let p = LimitedProvider;
487 assert!(!p.capabilities().content_search);
488 assert!(p.capabilities().filename_search);
489 }
490
491 #[test]
492 fn search_provider_returns_none_for_path() {
493 struct SelectiveSearchFs {
494 inner: InMemoryFs,
495 }
496
497 #[async_trait::async_trait]
498 impl FileSystemExt for SelectiveSearchFs {}
499
500 #[async_trait::async_trait]
501 impl FileSystem for SelectiveSearchFs {
502 async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
503 self.inner.read_file(path).await
504 }
505 async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
506 self.inner.write_file(path, content).await
507 }
508 async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
509 self.inner.append_file(path, content).await
510 }
511 async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
512 self.inner.mkdir(path, recursive).await
513 }
514 async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
515 self.inner.remove(path, recursive).await
516 }
517 async fn stat(&self, path: &Path) -> Result<crate::fs::Metadata> {
518 self.inner.stat(path).await
519 }
520 async fn read_dir(&self, path: &Path) -> Result<Vec<crate::fs::DirEntry>> {
521 self.inner.read_dir(path).await
522 }
523 async fn exists(&self, path: &Path) -> Result<bool> {
524 self.inner.exists(path).await
525 }
526 async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
527 self.inner.rename(from, to).await
528 }
529 async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
530 self.inner.copy(from, to).await
531 }
532 async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
533 self.inner.symlink(target, link).await
534 }
535 async fn read_link(&self, path: &Path) -> Result<std::path::PathBuf> {
536 self.inner.read_link(path).await
537 }
538 async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
539 self.inner.chmod(path, mode).await
540 }
541 fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
542 Some(self)
543 }
544 }
545
546 impl SearchCapable for SelectiveSearchFs {
547 fn search_provider(&self, path: &Path) -> Option<Box<dyn SearchProvider>> {
548 if path.starts_with("/indexed") {
550 Some(Box::new(MockProvider { results: vec![] }))
551 } else {
552 None
553 }
554 }
555 }
556
557 let fs = SelectiveSearchFs {
558 inner: InMemoryFs::new(),
559 };
560 assert!(fs.search_provider(Path::new("/indexed")).is_some());
562 assert!(fs.search_provider(Path::new("/other")).is_none());
563 }
564
565 #[test]
566 fn search_provider_error_result() {
567 struct ErrorProvider;
568 impl SearchProvider for ErrorProvider {
569 fn search(&self, _query: &SearchQuery) -> Result<SearchResults> {
570 Err(crate::Error::Io(std::io::Error::other("index corrupted")))
571 }
572 fn capabilities(&self) -> SearchCapabilities {
573 SearchCapabilities {
574 content_search: true,
575 ..SearchCapabilities::default()
576 }
577 }
578 }
579 let p = ErrorProvider;
580 let result = p.search(&SearchQuery {
581 pattern: "x".into(),
582 is_regex: false,
583 case_insensitive: false,
584 root: PathBuf::from("/"),
585 glob_filter: None,
586 max_results: None,
587 });
588 assert!(result.is_err());
589 let msg = format!("{}", result.unwrap_err());
590 assert!(msg.contains("index corrupted"));
591 }
592
593 #[test]
594 fn search_capabilities_debug() {
595 let c = SearchCapabilities::default();
596 let dbg = format!("{:?}", c);
597 assert!(dbg.contains("SearchCapabilities"));
598 }
599
600 #[test]
601 fn search_query_debug() {
602 let q = SearchQuery {
603 pattern: "hello".into(),
604 is_regex: false,
605 case_insensitive: false,
606 root: PathBuf::from("/"),
607 glob_filter: None,
608 max_results: None,
609 };
610 let dbg = format!("{:?}", q);
611 assert!(dbg.contains("hello"));
612 }
613}