Skip to main content

termesh_filesystem/
ignore_rules.rs

1//! Ignore semantics for the explorer (ADR-0005 §4).
2//!
3//! Uses ripgrep's `ignore` crate for the matching itself — `.gitignore` precedence is a
4//! known tar pit, and the explorer and the content search share this matcher so the two
5//! agree on what exists. A file the tree hides but search finds is a bug report.
6//!
7//! The ignore *files* are read through [`FileSystemService`] rather than by the crate's
8//! own I/O, which keeps the service boundary intact (CONTRIBUTING.md invariants) and — more
9//! usefully — makes all of this testable against the in-memory fake.
10
11use std::path::{Path, PathBuf};
12
13use ignore::gitignore::{Gitignore, GitignoreBuilder};
14
15use crate::service::{DirEntryInfo, EntryKind, FileSystemService};
16
17/// What the explorer shows. Both default to off: the default view should look like the
18/// project, and the agent's context should not be full of `target/` and `node_modules/`.
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub struct IgnoreOptions {
21    /// Show entries matched by an ignore file (rendered dimmed by the widget).
22    pub show_ignored: bool,
23    /// Show dotfiles.
24    pub show_hidden: bool,
25}
26
27impl IgnoreOptions {
28    /// Show everything — useful for "reveal in explorer" and for tests.
29    pub fn show_all() -> Self {
30        Self { show_ignored: true, show_hidden: true }
31    }
32}
33
34/// The ignore-file names we honour, in the order git itself applies them.
35const IGNORE_FILES: &[&str] = &[".gitignore", ".ignore"];
36
37/// A chain of matchers, one per directory that contributed rules. Deeper directories win,
38/// which is what `.gitignore` nesting means.
39pub struct IgnoreRules {
40    matchers: Vec<(PathBuf, Gitignore)>,
41    options: IgnoreOptions,
42    root: PathBuf,
43}
44
45impl IgnoreRules {
46    /// Build the root-level rules, reading ignore files through `fs`.
47    pub fn for_root(fs: &dyn FileSystemService, root: &Path, options: IgnoreOptions) -> Self {
48        let mut rules = Self { matchers: Vec::new(), options, root: root.to_path_buf() };
49        rules.load_dir(fs, root);
50        // git's own repo-local excludes, which live outside the working tree.
51        rules.load_file(fs, root, &root.join(".git/info/exclude"));
52        rules
53    }
54
55    /// No rules at all — every entry is visible.
56    pub fn disabled() -> Self {
57        Self { matchers: Vec::new(), options: IgnoreOptions::show_all(), root: PathBuf::new() }
58    }
59
60    pub fn options(&self) -> IgnoreOptions {
61        self.options
62    }
63
64    /// Read `dir`'s own ignore files, if it has any we have not already loaded.
65    ///
66    /// Called before listing each directory, so nesting is honoured lazily: we only pay
67    /// for the rules of directories the user actually opened.
68    pub fn load_dir(&mut self, fs: &dyn FileSystemService, dir: &Path) {
69        if self.matchers.iter().any(|(d, _)| d == dir) {
70            return;
71        }
72        for name in IGNORE_FILES {
73            let path = dir.join(name);
74            self.load_file(fs, dir, &path);
75        }
76    }
77
78    fn load_file(&mut self, fs: &dyn FileSystemService, anchor: &Path, path: &Path) {
79        // A missing ignore file is the normal case, not an error worth surfacing.
80        let Ok(bytes) = fs.read_file(path) else { return };
81        let Ok(text) = String::from_utf8(bytes) else { return };
82
83        let mut builder = GitignoreBuilder::new(anchor);
84        let mut added = false;
85        for line in text.lines() {
86            // A malformed glob should cost us that line, not the whole file.
87            if builder.add_line(None, line).is_ok() {
88                added = true;
89            }
90        }
91        if !added {
92            return;
93        }
94        if let Ok(matcher) = builder.build() {
95            self.matchers.push((anchor.to_path_buf(), matcher));
96        }
97    }
98
99    /// Whether `path` should be hidden from the explorer.
100    pub fn is_hidden(&self, path: &Path, is_dir: bool) -> bool {
101        let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
102
103        // `.git` is machinery, never content. Hidden unless dotfiles are shown.
104        if !self.options.show_hidden && name.starts_with('.') {
105            return true;
106        }
107        if self.options.show_ignored {
108            return false;
109        }
110        self.is_ignored(path, is_dir)
111    }
112
113    /// Whether an ignore file matches `path`. Deepest matcher wins; an explicit
114    /// whitelist (`!pattern`) at that depth un-ignores it.
115    pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
116        // Deepest anchor first, so a nested .gitignore overrides the root's.
117        let mut candidates: Vec<&(PathBuf, Gitignore)> =
118            self.matchers.iter().filter(|(dir, _)| path.starts_with(dir)).collect();
119        candidates.sort_by_key(|(dir, _)| std::cmp::Reverse(dir.components().count()));
120
121        for (_, matcher) in candidates {
122            let m = matcher.matched(path, is_dir);
123            if m.is_ignore() {
124                return true;
125            }
126            if m.is_whitelist() {
127                return false;
128            }
129        }
130        false
131    }
132
133    /// Drop the entries the explorer should not show.
134    pub fn filter(&self, entries: Vec<DirEntryInfo>) -> Vec<DirEntryInfo> {
135        entries.into_iter().filter(|e| !self.is_hidden(&e.path, e.kind == EntryKind::Dir)).collect()
136    }
137
138    pub fn root(&self) -> &Path {
139        &self.root
140    }
141}
142
143/// Whether `path` matches one of `patterns` — literal globs from `config.toml`'s
144/// `exclusions` key (ADR-0014 Task 3), never a `.gitignore` file.
145///
146/// A one-off matcher, not a field on [`IgnoreRules`]: the pattern list is short and
147/// changes only when the user edits their config, so recompiling per call trades a
148/// negligible cost for never going stale after a `config.reload` (Task 5) — no cache to
149/// invalidate. `patterns` is anchored at `root`, exactly as a root-level `.gitignore`
150/// would be.
151pub fn matches_exclusion(root: &Path, patterns: &[String], path: &Path, is_dir: bool) -> bool {
152    if patterns.is_empty() {
153        return false;
154    }
155    let mut builder = GitignoreBuilder::new(root);
156    for pattern in patterns {
157        // A malformed glob should cost the user that one pattern, not the whole list.
158        let _ = builder.add_line(None, pattern);
159    }
160    let Ok(matcher) = builder.build() else { return false };
161    matcher.matched(path, is_dir).is_ignore()
162}
163
164impl std::fmt::Debug for IgnoreRules {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("IgnoreRules")
167            .field("root", &self.root)
168            .field("options", &self.options)
169            .field("matchers", &self.matchers.len())
170            .finish()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::path::PathBuf;
178
179    use termesh_core::{FsError, FsResult};
180
181    /// Minimal in-crate fake: `test-support`'s richer one depends on this crate.
182    #[derive(Default)]
183    struct Files(Vec<(PathBuf, Vec<u8>)>);
184
185    impl Files {
186        fn with(pairs: &[(&str, &str)]) -> Self {
187            Self(pairs.iter().map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())).collect())
188        }
189    }
190
191    impl FileSystemService for Files {
192        fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
193            self.0
194                .iter()
195                .find(|(p, _)| p == path)
196                .map(|(_, c)| c.clone())
197                .ok_or_else(|| FsError::NotFound(path.to_path_buf()))
198        }
199        fn read_dir(&self, _: &Path) -> FsResult<Vec<DirEntryInfo>> {
200            Ok(Vec::new())
201        }
202        fn create_file(&self, _: &Path) -> FsResult<()> {
203            Ok(())
204        }
205        fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
206            Ok(())
207        }
208        fn create_dir(&self, _: &Path) -> FsResult<()> {
209            Ok(())
210        }
211        fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
212            Ok(())
213        }
214        fn remove_file(&self, _: &Path) -> FsResult<()> {
215            Ok(())
216        }
217        fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
218            Ok(())
219        }
220        fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
221            Ok(p.to_path_buf())
222        }
223    }
224
225    fn rules(files: &[(&str, &str)], options: IgnoreOptions) -> IgnoreRules {
226        IgnoreRules::for_root(&Files::with(files), Path::new("/r"), options)
227    }
228
229    fn default_rules(files: &[(&str, &str)]) -> IgnoreRules {
230        rules(files, IgnoreOptions::default())
231    }
232
233    #[test]
234    fn a_configured_exclusion_matches_like_a_gitignore_pattern() {
235        assert!(matches_exclusion(
236            Path::new("/r"),
237            &["*.log".to_string()],
238            Path::new("/r/debug.log"),
239            false,
240        ));
241        assert!(!matches_exclusion(
242            Path::new("/r"),
243            &["*.log".to_string()],
244            Path::new("/r/src"),
245            true,
246        ));
247    }
248
249    #[test]
250    fn no_patterns_excludes_nothing() {
251        assert!(!matches_exclusion(Path::new("/r"), &[], Path::new("/r/anything"), false));
252    }
253
254    #[test]
255    fn a_malformed_pattern_costs_only_itself() {
256        let patterns = vec!["[".to_string(), "*.log".to_string()];
257        assert!(matches_exclusion(Path::new("/r"), &patterns, Path::new("/r/debug.log"), false));
258    }
259
260    #[test]
261    fn gitignore_patterns_hide_matching_entries() {
262        let r = default_rules(&[("/r/.gitignore", "target\nnode_modules\n*.log\n")]);
263        assert!(r.is_hidden(Path::new("/r/target"), true));
264        assert!(r.is_hidden(Path::new("/r/node_modules"), true));
265        assert!(r.is_hidden(Path::new("/r/debug.log"), false));
266        assert!(!r.is_hidden(Path::new("/r/src"), true));
267    }
268
269    #[test]
270    fn dotfiles_are_hidden_by_default() {
271        let r = default_rules(&[]);
272        assert!(r.is_hidden(Path::new("/r/.git"), true));
273        assert!(r.is_hidden(Path::new("/r/.env"), false));
274        assert!(!r.is_hidden(Path::new("/r/README.md"), false));
275    }
276
277    #[test]
278    fn show_hidden_reveals_dotfiles_but_still_honours_ignore_files() {
279        let r = rules(
280            &[("/r/.gitignore", "target\n")],
281            IgnoreOptions { show_hidden: true, show_ignored: false },
282        );
283        assert!(!r.is_hidden(Path::new("/r/.env"), false), "dotfile now visible");
284        assert!(r.is_hidden(Path::new("/r/target"), true), "ignore rules still apply");
285    }
286
287    #[test]
288    fn show_ignored_reveals_ignored_entries() {
289        let r = rules(
290            &[("/r/.gitignore", "target\n")],
291            IgnoreOptions { show_ignored: true, show_hidden: true },
292        );
293        assert!(!r.is_hidden(Path::new("/r/target"), true));
294    }
295
296    #[test]
297    fn whitelist_patterns_un_ignore() {
298        let r = default_rules(&[("/r/.gitignore", "*.log\n!keep.log\n")]);
299        assert!(r.is_hidden(Path::new("/r/debug.log"), false));
300        assert!(!r.is_hidden(Path::new("/r/keep.log"), false), "! should win");
301    }
302
303    #[test]
304    fn dot_ignore_files_are_honoured_alongside_gitignore() {
305        let r = default_rules(&[("/r/.ignore", "secrets\n")]);
306        assert!(r.is_hidden(Path::new("/r/secrets"), true));
307    }
308
309    #[test]
310    fn git_info_exclude_is_honoured() {
311        let r = default_rules(&[("/r/.git/info/exclude", "scratch\n")]);
312        assert!(r.is_hidden(Path::new("/r/scratch"), true));
313    }
314
315    #[test]
316    fn a_nested_gitignore_overrides_the_root() {
317        let fs = Files::with(&[("/r/.gitignore", "*.log\n"), ("/r/logs/.gitignore", "!*.log\n")]);
318        let mut r = IgnoreRules::for_root(&fs, Path::new("/r"), IgnoreOptions::default());
319        r.load_dir(&fs, Path::new("/r/logs"));
320
321        assert!(r.is_hidden(Path::new("/r/debug.log"), false), "root rule still applies");
322        assert!(!r.is_hidden(Path::new("/r/logs/debug.log"), false), "the deeper .gitignore wins");
323    }
324
325    #[test]
326    fn a_missing_gitignore_is_not_an_error() {
327        let r = default_rules(&[]);
328        assert!(!r.is_hidden(Path::new("/r/anything.txt"), false));
329    }
330
331    #[test]
332    fn comments_and_blank_lines_are_ignored() {
333        let r = default_rules(&[("/r/.gitignore", "# a comment\n\n  \ntarget\n")]);
334        assert!(r.is_hidden(Path::new("/r/target"), true));
335        assert!(!r.is_hidden(Path::new("/r/src"), true));
336    }
337
338    #[test]
339    fn filter_drops_hidden_entries_and_keeps_the_rest() {
340        let r = default_rules(&[("/r/.gitignore", "target\n")]);
341        let entries = vec![
342            DirEntryInfo { name: "src".into(), path: "/r/src".into(), kind: EntryKind::Dir },
343            DirEntryInfo { name: "target".into(), path: "/r/target".into(), kind: EntryKind::Dir },
344            DirEntryInfo { name: ".git".into(), path: "/r/.git".into(), kind: EntryKind::Dir },
345            DirEntryInfo {
346                name: "README.md".into(),
347                path: "/r/README.md".into(),
348                kind: EntryKind::File,
349            },
350        ];
351        let kept: Vec<String> =
352            r.filter(entries).iter().map(|e| e.name.to_string_lossy().into_owned()).collect();
353        assert_eq!(kept, ["src", "README.md"]);
354    }
355
356    #[test]
357    fn disabled_rules_show_everything() {
358        let r = IgnoreRules::disabled();
359        assert!(!r.is_hidden(Path::new("/r/.git"), true));
360        assert!(!r.is_hidden(Path::new("/r/target"), true));
361    }
362
363    #[test]
364    fn a_directory_only_pattern_does_not_hide_a_file_of_the_same_name() {
365        let r = default_rules(&[("/r/.gitignore", "build/\n")]);
366        assert!(r.is_hidden(Path::new("/r/build"), true), "the directory is ignored");
367        assert!(!r.is_hidden(Path::new("/r/build"), false), "a file named build is not");
368    }
369}