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 owner: "u".into(),
222 group: "g".into(),
223 author: "u".into(),
224 context: String::new(),
225 }
226 }
227
228 #[test]
229 fn parse_and_match_basic_globs() {
230 let dir = temp_dir();
231 fs::write(
232 dir.join(".gitignore"),
233 "*.o\n# comment\n/build\ntmp/\n!keep.o\n",
234 )
235 .unwrap();
236 let set = IgnoreSet::load_for_dir(&dir);
237 assert!(set.ignores(&entry_in(&dir, "foo.o", false)));
238 assert!(!set.ignores(&entry_in(&dir, "foo.rs", false)));
239 assert!(
240 set.ignores(&entry_in(&dir, "build", false)),
241 "anchored /build should match name build"
242 );
243 assert!(set.ignores(&entry_in(&dir, "tmp", true)));
244 assert!(!set.ignores(&entry_in(&dir, "tmp", false))); assert!(!set.ignores(&entry_in(&dir, "keep.o", false)));
247 let _ = fs::remove_dir_all(&dir);
248 }
249
250 #[test]
251 fn f00ignore_and_gitignore_both_loaded() {
252 let dir = temp_dir();
253 fs::write(dir.join(".f00ignore"), "secret.txt\n").unwrap();
254 fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
255 let set = IgnoreSet::load_for_dir(&dir);
256 assert!(set.ignores(&entry_in(&dir, "secret.txt", false)));
257 assert!(set.ignores(&entry_in(&dir, "a.log", false)));
258 assert!(!set.ignores(&entry_in(&dir, "a.txt", false)));
259 let _ = fs::remove_dir_all(&dir);
260 }
261
262 #[test]
263 fn nested_path_unanchored_glob() {
264 let dir = temp_dir();
265 fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
266 let set = IgnoreSet::load_for_dir(&dir);
267 let mut e = entry_in(&dir, "sub/x.o", false);
268 e.path = dir.join("sub").join("x.o");
269 e.name = "x.o".into();
270 assert!(set.ignores(&e));
271 let _ = fs::remove_dir_all(&dir);
272 }
273
274 #[test]
275 fn apply_ignore_set_filters() {
276 let dir = temp_dir();
277 fs::write(dir.join(".gitignore"), "hide_me\n").unwrap();
278 let set = IgnoreSet::load_for_dir(&dir);
279 let mut entries = vec![
280 entry_in(&dir, "keep", false),
281 entry_in(&dir, "hide_me", false),
282 ];
283 apply_ignore_set(&mut entries, &set);
284 assert_eq!(entries.len(), 1);
285 assert_eq!(entries[0].name, "keep");
286 let _ = fs::remove_dir_all(&dir);
287 }
288}