1use std::path::{Path, PathBuf};
12
13use ignore::gitignore::{Gitignore, GitignoreBuilder};
14
15use crate::service::{DirEntryInfo, EntryKind, FileSystemService};
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub struct IgnoreOptions {
21 pub show_ignored: bool,
23 pub show_hidden: bool,
25}
26
27impl IgnoreOptions {
28 pub fn show_all() -> Self {
30 Self { show_ignored: true, show_hidden: true }
31 }
32}
33
34const IGNORE_FILES: &[&str] = &[".gitignore", ".ignore"];
36
37pub struct IgnoreRules {
40 matchers: Vec<(PathBuf, Gitignore)>,
41 options: IgnoreOptions,
42 root: PathBuf,
43}
44
45impl IgnoreRules {
46 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 rules.load_file(fs, root, &root.join(".git/info/exclude"));
52 rules
53 }
54
55 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 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 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 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 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 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 pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
116 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_or_any_parents(path, is_dir);
131 if m.is_ignore() {
132 return true;
133 }
134 if m.is_whitelist() {
135 return false;
136 }
137 }
138 false
139 }
140
141 pub fn filter(&self, entries: Vec<DirEntryInfo>) -> Vec<DirEntryInfo> {
143 entries.into_iter().filter(|e| !self.is_hidden(&e.path, e.kind == EntryKind::Dir)).collect()
144 }
145
146 pub fn root(&self) -> &Path {
147 &self.root
148 }
149}
150
151pub fn matches_exclusion(root: &Path, patterns: &[String], path: &Path, is_dir: bool) -> bool {
160 if patterns.is_empty() {
161 return false;
162 }
163 let mut builder = GitignoreBuilder::new(root);
164 for pattern in patterns {
165 let _ = builder.add_line(None, pattern);
167 }
168 let Ok(matcher) = builder.build() else { return false };
169 matcher.matched(path, is_dir).is_ignore()
170}
171
172impl std::fmt::Debug for IgnoreRules {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("IgnoreRules")
175 .field("root", &self.root)
176 .field("options", &self.options)
177 .field("matchers", &self.matchers.len())
178 .finish()
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use std::path::PathBuf;
186
187 use termesh_core::{FsError, FsResult};
188
189 #[derive(Default)]
191 struct Files(Vec<(PathBuf, Vec<u8>)>);
192
193 impl Files {
194 fn with(pairs: &[(&str, &str)]) -> Self {
195 Self(pairs.iter().map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())).collect())
196 }
197 }
198
199 impl FileSystemService for Files {
200 fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
201 self.0
202 .iter()
203 .find(|(p, _)| p == path)
204 .map(|(_, c)| c.clone())
205 .ok_or_else(|| FsError::NotFound(path.to_path_buf()))
206 }
207 fn read_dir(&self, _: &Path) -> FsResult<Vec<DirEntryInfo>> {
208 Ok(Vec::new())
209 }
210 fn create_file(&self, _: &Path) -> FsResult<()> {
211 Ok(())
212 }
213 fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
214 Ok(())
215 }
216 fn create_dir(&self, _: &Path) -> FsResult<()> {
217 Ok(())
218 }
219 fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
220 Ok(())
221 }
222 fn remove_file(&self, _: &Path) -> FsResult<()> {
223 Ok(())
224 }
225 fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
226 Ok(())
227 }
228 fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
229 Ok(p.to_path_buf())
230 }
231 }
232
233 fn rules(files: &[(&str, &str)], options: IgnoreOptions) -> IgnoreRules {
234 IgnoreRules::for_root(&Files::with(files), Path::new("/r"), options)
235 }
236
237 fn default_rules(files: &[(&str, &str)]) -> IgnoreRules {
238 rules(files, IgnoreOptions::default())
239 }
240
241 #[test]
249 fn an_ignored_directory_hides_the_files_underneath_it() {
250 let rules = default_rules(&[("/r/.gitignore", "target\n")]);
251
252 assert!(rules.is_hidden(Path::new("/r/target"), true), "the directory itself");
253 assert!(rules.is_hidden(Path::new("/r/target/debug"), true), "a directory inside it");
254 assert!(
255 rules.is_hidden(Path::new("/r/target/debug/deps/orders.rlib"), false),
256 "a file several levels down"
257 );
258 assert!(!rules.is_hidden(Path::new("/r/src/main.rs"), false), "and nothing else");
259 }
260
261 #[test]
262 fn a_configured_exclusion_matches_like_a_gitignore_pattern() {
263 assert!(matches_exclusion(
264 Path::new("/r"),
265 &["*.log".to_string()],
266 Path::new("/r/debug.log"),
267 false,
268 ));
269 assert!(!matches_exclusion(
270 Path::new("/r"),
271 &["*.log".to_string()],
272 Path::new("/r/src"),
273 true,
274 ));
275 }
276
277 #[test]
278 fn no_patterns_excludes_nothing() {
279 assert!(!matches_exclusion(Path::new("/r"), &[], Path::new("/r/anything"), false));
280 }
281
282 #[test]
283 fn a_malformed_pattern_costs_only_itself() {
284 let patterns = vec!["[".to_string(), "*.log".to_string()];
285 assert!(matches_exclusion(Path::new("/r"), &patterns, Path::new("/r/debug.log"), false));
286 }
287
288 #[test]
289 fn gitignore_patterns_hide_matching_entries() {
290 let r = default_rules(&[("/r/.gitignore", "target\nnode_modules\n*.log\n")]);
291 assert!(r.is_hidden(Path::new("/r/target"), true));
292 assert!(r.is_hidden(Path::new("/r/node_modules"), true));
293 assert!(r.is_hidden(Path::new("/r/debug.log"), false));
294 assert!(!r.is_hidden(Path::new("/r/src"), true));
295 }
296
297 #[test]
298 fn dotfiles_are_hidden_by_default() {
299 let r = default_rules(&[]);
300 assert!(r.is_hidden(Path::new("/r/.git"), true));
301 assert!(r.is_hidden(Path::new("/r/.env"), false));
302 assert!(!r.is_hidden(Path::new("/r/README.md"), false));
303 }
304
305 #[test]
306 fn show_hidden_reveals_dotfiles_but_still_honours_ignore_files() {
307 let r = rules(
308 &[("/r/.gitignore", "target\n")],
309 IgnoreOptions { show_hidden: true, show_ignored: false },
310 );
311 assert!(!r.is_hidden(Path::new("/r/.env"), false), "dotfile now visible");
312 assert!(r.is_hidden(Path::new("/r/target"), true), "ignore rules still apply");
313 }
314
315 #[test]
316 fn show_ignored_reveals_ignored_entries() {
317 let r = rules(
318 &[("/r/.gitignore", "target\n")],
319 IgnoreOptions { show_ignored: true, show_hidden: true },
320 );
321 assert!(!r.is_hidden(Path::new("/r/target"), true));
322 }
323
324 #[test]
325 fn whitelist_patterns_un_ignore() {
326 let r = default_rules(&[("/r/.gitignore", "*.log\n!keep.log\n")]);
327 assert!(r.is_hidden(Path::new("/r/debug.log"), false));
328 assert!(!r.is_hidden(Path::new("/r/keep.log"), false), "! should win");
329 }
330
331 #[test]
332 fn dot_ignore_files_are_honoured_alongside_gitignore() {
333 let r = default_rules(&[("/r/.ignore", "secrets\n")]);
334 assert!(r.is_hidden(Path::new("/r/secrets"), true));
335 }
336
337 #[test]
338 fn git_info_exclude_is_honoured() {
339 let r = default_rules(&[("/r/.git/info/exclude", "scratch\n")]);
340 assert!(r.is_hidden(Path::new("/r/scratch"), true));
341 }
342
343 #[test]
344 fn a_nested_gitignore_overrides_the_root() {
345 let fs = Files::with(&[("/r/.gitignore", "*.log\n"), ("/r/logs/.gitignore", "!*.log\n")]);
346 let mut r = IgnoreRules::for_root(&fs, Path::new("/r"), IgnoreOptions::default());
347 r.load_dir(&fs, Path::new("/r/logs"));
348
349 assert!(r.is_hidden(Path::new("/r/debug.log"), false), "root rule still applies");
350 assert!(!r.is_hidden(Path::new("/r/logs/debug.log"), false), "the deeper .gitignore wins");
351 }
352
353 #[test]
354 fn a_missing_gitignore_is_not_an_error() {
355 let r = default_rules(&[]);
356 assert!(!r.is_hidden(Path::new("/r/anything.txt"), false));
357 }
358
359 #[test]
360 fn comments_and_blank_lines_are_ignored() {
361 let r = default_rules(&[("/r/.gitignore", "# a comment\n\n \ntarget\n")]);
362 assert!(r.is_hidden(Path::new("/r/target"), true));
363 assert!(!r.is_hidden(Path::new("/r/src"), true));
364 }
365
366 #[test]
367 fn filter_drops_hidden_entries_and_keeps_the_rest() {
368 let r = default_rules(&[("/r/.gitignore", "target\n")]);
369 let entries = vec![
370 DirEntryInfo { name: "src".into(), path: "/r/src".into(), kind: EntryKind::Dir },
371 DirEntryInfo { name: "target".into(), path: "/r/target".into(), kind: EntryKind::Dir },
372 DirEntryInfo { name: ".git".into(), path: "/r/.git".into(), kind: EntryKind::Dir },
373 DirEntryInfo {
374 name: "README.md".into(),
375 path: "/r/README.md".into(),
376 kind: EntryKind::File,
377 },
378 ];
379 let kept: Vec<String> =
380 r.filter(entries).iter().map(|e| e.name.to_string_lossy().into_owned()).collect();
381 assert_eq!(kept, ["src", "README.md"]);
382 }
383
384 #[test]
385 fn disabled_rules_show_everything() {
386 let r = IgnoreRules::disabled();
387 assert!(!r.is_hidden(Path::new("/r/.git"), true));
388 assert!(!r.is_hidden(Path::new("/r/target"), true));
389 }
390
391 #[test]
392 fn a_directory_only_pattern_does_not_hide_a_file_of_the_same_name() {
393 let r = default_rules(&[("/r/.gitignore", "build/\n")]);
394 assert!(r.is_hidden(Path::new("/r/build"), true), "the directory is ignored");
395 assert!(!r.is_hidden(Path::new("/r/build"), false), "a file named build is not");
396 }
397}