1use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::entry::Entry;
12use crate::filter::glob_match;
13
14pub const IGNORE_FILE_NAMES: &[&str] = &[".f00ignore", ".gitignore"];
16
17#[derive(Debug, Clone)]
18struct IgnorePattern {
19 pattern: String,
21 anchored: bool,
23 dir_only: bool,
25 negated: bool,
27}
28
29#[derive(Debug, Clone, Default)]
31pub struct IgnoreSet {
32 pub base: PathBuf,
34 patterns: Vec<IgnorePattern>,
35}
36
37impl IgnoreSet {
38 pub fn load_for_dir(dir: &Path) -> Self {
41 let mut set = Self {
42 base: dir.to_path_buf(),
43 patterns: Vec::new(),
44 };
45 for name in IGNORE_FILE_NAMES {
46 let path = dir.join(name);
47 if let Ok(text) = fs::read_to_string(&path) {
48 set.patterns.extend(parse_ignore_text(&text));
49 }
50 }
51 set
52 }
53
54 pub fn is_empty(&self) -> bool {
55 self.patterns.is_empty()
56 }
57
58 pub fn ignores(&self, entry: &Entry) -> bool {
63 if self.patterns.is_empty() {
64 return false;
65 }
66 if entry.name == "." || entry.name == ".." {
68 return false;
69 }
70
71 let rel = relative_path(&self.base, &entry.path).unwrap_or_else(|| entry.name.clone());
72 let rel = rel.replace('\\', "/");
73 let rel = rel.trim_start_matches("./");
74 if rel.is_empty() {
75 return false;
76 }
77
78 let is_dir = entry.is_dir();
79 let mut ignored = false;
80 for pat in &self.patterns {
81 if pat.dir_only && !is_dir {
82 continue;
83 }
84 if pattern_matches(pat, rel) {
85 ignored = !pat.negated;
86 }
87 }
88 ignored
89 }
90}
91
92pub fn load_ignore_set(dir: &Path) -> IgnoreSet {
94 IgnoreSet::load_for_dir(dir)
95}
96
97fn parse_ignore_text(text: &str) -> Vec<IgnorePattern> {
98 let mut out = Vec::new();
99 for line in text.lines() {
100 let line = line.trim().trim_end_matches('\r');
102 if line.is_empty() || line.starts_with('#') {
103 continue;
104 }
105 let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
106 (true, r)
107 } else {
108 (false, line)
109 };
110 if rest.is_empty() {
111 continue;
112 }
113 let (anchored, rest) = if let Some(r) = rest.strip_prefix('/') {
114 (true, r)
115 } else {
116 (false, rest)
117 };
118 let (dir_only, pattern) = if let Some(r) = rest.strip_suffix('/') {
119 (true, r.to_string())
120 } else {
121 (false, rest.to_string())
122 };
123 if pattern.is_empty() {
124 continue;
125 }
126 out.push(IgnorePattern {
127 pattern,
128 anchored,
129 dir_only,
130 negated,
131 });
132 }
133 out
134}
135
136fn pattern_matches(pat: &IgnorePattern, rel: &str) -> bool {
137 let pattern = pat.pattern.as_str();
138 if pat.anchored || pattern.contains('/') {
141 return glob_match(pattern, rel)
142 || (rel.ends_with('/') && glob_match(pattern, rel.trim_end_matches('/')));
143 }
144
145 if glob_match(pattern, rel) {
147 return true;
148 }
149 for segment in rel.split('/') {
150 if glob_match(pattern, segment) {
151 return true;
152 }
153 }
154 false
155}
156
157fn relative_path(base: &Path, path: &Path) -> Option<String> {
158 if let Ok(p) = path.strip_prefix(base) {
159 return Some(p.to_string_lossy().replace('\\', "/"));
160 }
161 if path.parent() == Some(base) {
164 return path.file_name().map(|n| n.to_string_lossy().into_owned());
165 }
166 None
167}
168
169pub fn apply_ignore_set(entries: &mut Vec<Entry>, set: &IgnoreSet) {
171 if set.is_empty() {
172 return;
173 }
174 entries.retain(|e| !set.ignores(e));
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::entry::{Entry, EntryKind, GitStatus};
181 use std::time::{SystemTime, UNIX_EPOCH};
182
183 fn temp_dir() -> PathBuf {
184 let base = std::env::temp_dir().join(format!(
185 "f00-core-ignore-{}-{}",
186 std::process::id(),
187 SystemTime::now()
188 .duration_since(UNIX_EPOCH)
189 .map(|d| d.as_nanos())
190 .unwrap_or(0)
191 ));
192 fs::create_dir_all(&base).unwrap();
193 base
194 }
195
196 fn entry_in(dir: &Path, name: &str, is_dir: bool) -> Entry {
197 Entry {
198 path: dir.join(name),
199 name: name.to_string(),
200 kind: if is_dir {
201 EntryKind::Directory
202 } else {
203 EntryKind::File
204 },
205 size: 0,
206 modified: None,
207 created: None,
208 accessed: None,
209 changed: None,
210 mode: 0,
211 readonly: false,
212 symlink_target: None,
213 depth: 0,
214 git_status: GitStatus::Clean,
215 is_dir_header: false,
216 nlink: 1,
217 uid: 0,
218 gid: 0,
219 inode: 0,
220 blocks: 0,
221 dev: 0,
222 rdev: 0,
223 blksize: 0,
224 owner: "u".into(),
225 group: "g".into(),
226 author: "u".into(),
227 context: String::new(),
228 }
229 }
230
231 #[test]
232 fn parse_and_match_basic_globs() {
233 let dir = temp_dir();
234 fs::write(
235 dir.join(".gitignore"),
236 "*.o\n# comment\n/build\ntmp/\n!keep.o\n",
237 )
238 .unwrap();
239 let set = IgnoreSet::load_for_dir(&dir);
240 assert!(set.ignores(&entry_in(&dir, "foo.o", false)));
241 assert!(!set.ignores(&entry_in(&dir, "foo.rs", false)));
242 assert!(
243 set.ignores(&entry_in(&dir, "build", false)),
244 "anchored /build should match name build"
245 );
246 assert!(set.ignores(&entry_in(&dir, "tmp", true)));
247 assert!(!set.ignores(&entry_in(&dir, "tmp", false))); assert!(!set.ignores(&entry_in(&dir, "keep.o", false)));
250 let _ = fs::remove_dir_all(&dir);
251 }
252
253 #[test]
254 fn f00ignore_and_gitignore_both_loaded() {
255 let dir = temp_dir();
256 fs::write(dir.join(".f00ignore"), "secret.txt\n").unwrap();
257 fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
258 let set = IgnoreSet::load_for_dir(&dir);
259 assert!(set.ignores(&entry_in(&dir, "secret.txt", false)));
260 assert!(set.ignores(&entry_in(&dir, "a.log", false)));
261 assert!(!set.ignores(&entry_in(&dir, "a.txt", false)));
262 let _ = fs::remove_dir_all(&dir);
263 }
264
265 #[test]
266 fn nested_path_unanchored_glob() {
267 let dir = temp_dir();
268 fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
269 let set = IgnoreSet::load_for_dir(&dir);
270 let mut e = entry_in(&dir, "sub/x.o", false);
271 e.path = dir.join("sub").join("x.o");
272 e.name = "x.o".into();
273 assert!(set.ignores(&e));
274 let _ = fs::remove_dir_all(&dir);
275 }
276
277 #[test]
278 fn apply_ignore_set_filters() {
279 let dir = temp_dir();
280 fs::write(dir.join(".gitignore"), "hide_me\n").unwrap();
281 let set = IgnoreSet::load_for_dir(&dir);
282 let mut entries = vec![
283 entry_in(&dir, "keep", false),
284 entry_in(&dir, "hide_me", false),
285 ];
286 apply_ignore_set(&mut entries, &set);
287 assert_eq!(entries.len(), 1);
288 assert_eq!(entries[0].name, "keep");
289 let _ = fs::remove_dir_all(&dir);
290 }
291}