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, 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 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
143pub 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 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 #[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}