Skip to main content

bashkit/fs/
search.rs

1// SearchCapable is a separate opt-in trait — FileSystem unchanged.
2// Builtins (grep) check via as_search_capable() at runtime, fall back to linear scan.
3
4//! Optional indexed search support for filesystem implementations.
5//!
6//! The [`SearchCapable`] trait allows filesystem implementations to provide
7//! optimized content and filename search. Commands like `grep` check for this
8//! at runtime via [`FileSystem::as_search_capable`] and fall back to linear
9//! scanning when unavailable.
10//!
11//! # Implementing SearchCapable
12//!
13//! ```rust
14//! use bashkit::{SearchCapable, SearchProvider, SearchQuery, SearchResults,
15//!     SearchCapabilities, SearchMatch};
16//! use bashkit::{async_trait, FileSystem, FileSystemExt, InMemoryFs, Result};
17//! use std::path::{Path, PathBuf};
18//!
19//! struct IndexedFs {
20//!     inner: InMemoryFs,
21//! }
22//!
23//! impl IndexedFs {
24//!     fn new() -> Self {
25//!         Self { inner: InMemoryFs::new() }
26//!     }
27//! }
28//!
29//! #[async_trait]
30//! impl FileSystemExt for IndexedFs {}
31//!
32//! #[async_trait]
33//! impl FileSystem for IndexedFs {
34//!     async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
35//!         self.inner.read_file(path).await
36//!     }
37//!     async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
38//!         self.inner.write_file(path, content).await
39//!     }
40//!     async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
41//!         self.inner.append_file(path, content).await
42//!     }
43//!     async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
44//!         self.inner.mkdir(path, recursive).await
45//!     }
46//!     async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
47//!         self.inner.remove(path, recursive).await
48//!     }
49//!     async fn stat(&self, path: &Path) -> Result<bashkit::Metadata> {
50//!         self.inner.stat(path).await
51//!     }
52//!     async fn read_dir(&self, path: &Path) -> Result<Vec<bashkit::DirEntry>> {
53//!         self.inner.read_dir(path).await
54//!     }
55//!     async fn exists(&self, path: &Path) -> Result<bool> {
56//!         self.inner.exists(path).await
57//!     }
58//!     async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
59//!         self.inner.rename(from, to).await
60//!     }
61//!     async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
62//!         self.inner.copy(from, to).await
63//!     }
64//!     async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
65//!         self.inner.symlink(target, link).await
66//!     }
67//!     async fn read_link(&self, path: &Path) -> Result<PathBuf> {
68//!         self.inner.read_link(path).await
69//!     }
70//!     async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
71//!         self.inner.chmod(path, mode).await
72//!     }
73//!     fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
74//!         Some(self)
75//!     }
76//! }
77//!
78//! struct MyProvider;
79//!
80//! impl SearchProvider for MyProvider {
81//!     fn search(&self, _query: &SearchQuery) -> Result<SearchResults> {
82//!         Ok(SearchResults::default())
83//!     }
84//!     fn capabilities(&self) -> SearchCapabilities {
85//!         SearchCapabilities {
86//!             regex: true,
87//!             glob_filter: true,
88//!             content_search: true,
89//!             filename_search: false,
90//!         }
91//!     }
92//! }
93//!
94//! impl SearchCapable for IndexedFs {
95//!     fn search_provider(&self, _path: &Path) -> Option<Box<dyn SearchProvider>> {
96//!         Some(Box::new(MyProvider))
97//!     }
98//! }
99//!
100//! # #[tokio::main]
101//! # async fn main() -> Result<()> {
102//! let fs = std::sync::Arc::new(IndexedFs::new());
103//! let mut bash = bashkit::Bash::builder().fs(fs.clone()).build();
104//!
105//! // The grep builtin will check as_search_capable() and use indexed search
106//! // when available, falling back to linear scan otherwise.
107//! let sc = fs.as_search_capable().unwrap();
108//! let provider = sc.search_provider(Path::new("/")).unwrap();
109//! assert!(provider.capabilities().content_search);
110//! # Ok(())
111//! # }
112//! ```
113
114use std::path::{Path, PathBuf};
115
116use crate::error::Result;
117
118/// Optional trait for filesystems that support indexed search.
119///
120/// Builtins check for this via [`FileSystem::as_search_capable`](super::FileSystem::as_search_capable)
121/// and use it when available. Not implementing this trait has zero cost —
122/// builtins fall back to linear file enumeration.
123///
124/// `SearchCapable` is a supertrait of [`FileSystem`](super::FileSystem),
125/// meaning any type implementing `SearchCapable` must also implement
126/// `FileSystem`.
127pub trait SearchCapable: super::FileSystem {
128    /// Returns a search provider scoped to the given path.
129    /// Returns `None` if no index covers this path.
130    fn search_provider(&self, path: &Path) -> Option<Box<dyn SearchProvider>>;
131}
132
133/// Provides content and filename search within a filesystem scope.
134///
135/// Implementations are returned by [`SearchCapable::search_provider`] and
136/// execute queries against an index or other optimized data structure.
137pub trait SearchProvider: Send + Sync {
138    /// Execute a content search query.
139    fn search(&self, query: &SearchQuery) -> Result<SearchResults>;
140
141    /// Report what this provider can do.
142    fn capabilities(&self) -> SearchCapabilities;
143}
144
145/// Parameters for a search query.
146#[derive(Debug, Clone)]
147pub struct SearchQuery {
148    /// Pattern to search for.
149    pub pattern: String,
150    /// Whether the pattern is a regex (vs literal string).
151    pub is_regex: bool,
152    /// Case-insensitive matching.
153    pub case_insensitive: bool,
154    /// Root path to scope the search.
155    pub root: PathBuf,
156    /// Optional glob filter for filenames (e.g., `"*.rs"`).
157    pub glob_filter: Option<String>,
158    /// Maximum number of results to return.
159    pub max_results: Option<usize>,
160}
161
162/// Results from a search query.
163#[derive(Debug, Clone, Default)]
164pub struct SearchResults {
165    /// Matching lines.
166    pub matches: Vec<SearchMatch>,
167    /// Whether results were truncated due to `max_results`.
168    pub truncated: bool,
169}
170
171/// A single match from a search.
172#[derive(Debug, Clone)]
173pub struct SearchMatch {
174    /// Path to the file containing the match.
175    pub path: PathBuf,
176    /// 1-based line number within the file.
177    pub line_number: usize,
178    /// Content of the matching line (without trailing newline).
179    pub line_content: String,
180}
181
182/// Describes what a search provider supports.
183#[derive(Debug, Clone, Copy, Default)]
184pub struct SearchCapabilities {
185    /// Supports regex patterns.
186    pub regex: bool,
187    /// Supports glob-based file filtering.
188    pub glob_filter: bool,
189    /// Supports content (full-text) search.
190    pub content_search: bool,
191    /// Supports filename search.
192    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    /// Mock searchable filesystem for testing.
202    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    // --- Additional edge-case tests ---
396
397    #[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                // Only provide search for /indexed/ paths
549                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        // Path-scoped: /indexed returns provider, /other returns None
561        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}