1use std::path::{Path, PathBuf};
10
11use crate::walker::{IgnoreFilter, WalkerFs};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IgnoreScope {
16 Advisory,
19 Enforced,
22}
23
24#[derive(Debug, Clone)]
29pub struct IgnoreConfig {
30 scope: IgnoreScope,
31 ignore_files: Vec<String>,
32 use_defaults: bool,
33 auto_gitignore: bool,
34 use_global_gitignore: bool,
39 global_gitignore_path_override: Option<PathBuf>,
43}
44
45impl IgnoreConfig {
46 pub fn none() -> Self {
48 Self {
49 scope: IgnoreScope::Advisory,
50 ignore_files: Vec::new(),
51 use_defaults: false,
52 auto_gitignore: false,
53 use_global_gitignore: false,
54 global_gitignore_path_override: None,
55 }
56 }
57
58 pub fn interactive() -> Self {
64 Self {
65 scope: IgnoreScope::Advisory,
66 ..Self::agent()
67 }
68 }
69
70 pub fn agent() -> Self {
76 Self {
77 scope: IgnoreScope::Enforced,
78 ignore_files: vec![".gitignore".to_string()],
79 use_defaults: true,
80 auto_gitignore: true,
81 use_global_gitignore: false,
82 global_gitignore_path_override: None,
83 }
84 }
85
86 pub fn is_active(&self) -> bool {
88 self.use_defaults
89 || self.auto_gitignore
90 || !self.ignore_files.is_empty()
91 || self.use_global_gitignore
92 }
93
94 pub fn scope(&self) -> IgnoreScope {
95 self.scope
96 }
97
98 pub fn auto_gitignore(&self) -> bool {
100 self.auto_gitignore
101 }
102
103 pub fn use_defaults(&self) -> bool {
104 self.use_defaults
105 }
106
107 pub fn files(&self) -> &[String] {
108 &self.ignore_files
109 }
110
111 pub fn set_scope(&mut self, scope: IgnoreScope) {
112 self.scope = scope;
113 }
114
115 pub fn set_defaults(&mut self, on: bool) {
116 self.use_defaults = on;
117 }
118
119 pub fn set_auto_gitignore(&mut self, on: bool) {
120 self.auto_gitignore = on;
121 }
122
123 pub fn set_use_global_gitignore(&mut self, on: bool) {
128 self.use_global_gitignore = on;
129 }
130
131 pub fn use_global_gitignore(&self) -> bool {
132 self.use_global_gitignore
133 }
134
135 pub fn set_global_gitignore_path(&mut self, path: Option<PathBuf>) {
138 self.global_gitignore_path_override = path;
139 }
140
141 pub fn add_file(&mut self, name: &str) {
142 if !self.ignore_files.iter().any(|f| f == name) {
143 self.ignore_files.push(name.to_string());
144 }
145 }
146
147 pub fn remove_file(&mut self, name: &str) {
148 self.ignore_files.retain(|f| f != name);
149 }
150
151 pub fn clear(&mut self) {
152 self.ignore_files.clear();
153 self.use_defaults = false;
154 self.auto_gitignore = false;
155 self.use_global_gitignore = false;
156 self.global_gitignore_path_override = None;
157 }
158
159 pub async fn build_filter<F: WalkerFs>(
173 &self,
174 root: &Path,
175 fs: &F,
176 ) -> Option<IgnoreFilter> {
177 if !self.is_active() {
178 return None;
179 }
180
181 let mut filter = if self.use_defaults {
182 IgnoreFilter::with_defaults()
183 } else {
184 IgnoreFilter::new()
185 };
186
187 if self.use_global_gitignore {
192 let path = self
193 .global_gitignore_path_override
194 .clone()
195 .or_else(ignore::gitignore::gitconfig_excludes_path);
196 if let Some(path) = path
197 && let Ok(content) = std::fs::read_to_string(&path)
198 {
199 for line in content.lines() {
200 filter.add_rule(line);
201 }
202 }
203 }
204
205 let mut ancestors: Vec<(PathBuf, String)> = Vec::new();
210 let mut current = root;
211 while let Some(parent) = current.parent() {
212 if let Ok(rel) = root.strip_prefix(parent) {
214 ancestors.push((
215 parent.to_path_buf(),
216 rel.to_string_lossy().into_owned(),
217 ));
218 }
219 if parent == current {
220 break;
221 }
222 current = parent;
223 }
224 ancestors.reverse(); for (ancestor_dir, prefix) in &ancestors {
227 for filename in &self.ignore_files {
228 let path = ancestor_dir.join(filename);
229 if !fs.exists(&path).await {
230 continue;
231 }
232 let Ok(bytes) = fs.read_file(&path).await else {
233 continue;
234 };
235 let text = String::from_utf8_lossy(&bytes);
236 for line in text.lines() {
237 if let Some(rebased) = rebase_gitignore_line(line, prefix) {
238 filter.add_rule(&rebased);
239 }
240 }
241 }
242 }
243
244 for filename in &self.ignore_files {
246 let path = root.join(filename);
247 if let Ok(file_filter) = IgnoreFilter::from_gitignore(&path, fs).await {
248 filter.merge(&file_filter);
249 }
250 }
252
253 Some(filter)
254 }
255}
256
257fn rebase_gitignore_line(line: &str, prefix: &str) -> Option<String> {
271 let trimmed = line.trim();
272 if trimmed.is_empty() || trimmed.starts_with('#') {
273 return None;
274 }
275
276 let (negated, rest) = if let Some(stripped) = trimmed.strip_prefix('!') {
278 (true, stripped)
279 } else {
280 (false, trimmed)
281 };
282
283 let (dir_only, rest) = if let Some(stripped) = rest.strip_suffix('/') {
285 (true, stripped)
286 } else {
287 (false, rest)
288 };
289
290 let leading_slash = rest.starts_with('/');
294 let body = rest.trim_start_matches('/');
295 let is_anchored = leading_slash || body.contains('/');
296
297 let prefix = prefix.trim_matches('/');
298
299 let new_body: String = if !is_anchored {
300 body.to_string()
302 } else if prefix.is_empty() {
303 format!("/{body}")
306 } else {
307 if body == prefix {
311 return None;
314 }
315 let prefix_with_slash = format!("{prefix}/");
316 match body.strip_prefix(&prefix_with_slash) {
317 Some(stripped) => format!("/{stripped}"),
318 None => return None,
319 }
320 };
321
322 let mut out = String::new();
323 if negated {
324 out.push('!');
325 }
326 out.push_str(&new_body);
327 if dir_only {
328 out.push('/');
329 }
330 Some(out)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn test_none_is_inactive() {
339 let config = IgnoreConfig::none();
340 assert!(!config.is_active());
341 assert_eq!(config.scope(), IgnoreScope::Advisory);
342 assert!(!config.auto_gitignore());
343 }
344
345 #[test]
346 fn test_agent_preset_is_active() {
347 let config = IgnoreConfig::agent();
348 assert!(config.is_active());
349 assert_eq!(config.scope(), IgnoreScope::Enforced);
350 assert!(config.auto_gitignore());
351 assert!(config.use_defaults());
352 assert_eq!(config.files(), &[".gitignore"]);
353 }
354
355 #[test]
356 fn test_add_remove_files() {
357 let mut config = IgnoreConfig::none();
358 assert!(!config.is_active());
359
360 config.add_file(".dockerignore");
361 assert!(config.is_active());
362 assert_eq!(config.files(), &[".dockerignore"]);
363
364 config.add_file(".dockerignore");
366 assert_eq!(config.files().len(), 1);
367
368 config.remove_file(".dockerignore");
369 assert!(config.files().is_empty());
370 }
371
372 #[test]
373 fn test_clear() {
374 let mut config = IgnoreConfig::agent();
375 config.clear();
376 assert!(!config.is_active());
377 assert!(config.files().is_empty());
378 assert!(!config.use_defaults());
379 assert!(!config.auto_gitignore());
380 }
381
382 #[test]
383 fn test_set_scope() {
384 let mut config = IgnoreConfig::none();
385 config.set_scope(IgnoreScope::Enforced);
386 assert_eq!(config.scope(), IgnoreScope::Enforced);
387 }
388
389 #[test]
390 fn test_defaults_toggle() {
391 let mut config = IgnoreConfig::none();
392 config.set_defaults(true);
393 assert!(config.is_active());
394 config.set_defaults(false);
395 assert!(!config.is_active());
396 }
397
398 #[test]
399 fn test_auto_gitignore_alone_is_active() {
400 let mut config = IgnoreConfig::none();
401 assert!(!config.is_active());
402 config.set_auto_gitignore(true);
403 assert!(config.is_active());
404 }
405
406 mod async_tests {
407 use super::*;
408 use crate::walker::{WalkerDirEntry, WalkerError, WalkerFs};
409 use std::collections::HashMap;
410 use std::path::PathBuf;
411
412 struct MemEntry;
413 impl WalkerDirEntry for MemEntry {
414 fn name(&self) -> &str { "" }
415 fn is_dir(&self) -> bool { false }
416 fn is_file(&self) -> bool { true }
417 fn is_symlink(&self) -> bool { false }
418 }
419
420 struct FakeFs(HashMap<PathBuf, Vec<u8>>);
421
422 #[async_trait::async_trait]
423 impl WalkerFs for FakeFs {
424 type DirEntry = MemEntry;
425 async fn list_dir(&self, _: &Path) -> Result<Vec<MemEntry>, WalkerError> {
426 Ok(vec![])
427 }
428 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
429 self.0.get(path)
430 .cloned()
431 .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
432 }
433 async fn is_dir(&self, _: &Path) -> bool { false }
434 async fn exists(&self, path: &Path) -> bool { self.0.contains_key(path) }
435 }
436
437 #[tokio::test]
438 async fn test_build_filter_none_returns_none() {
439 let config = IgnoreConfig::none();
440 let fs = FakeFs(HashMap::new());
441 assert!(config.build_filter(Path::new("/"), &fs).await.is_none());
442 }
443
444 #[tokio::test]
445 async fn test_build_filter_defaults_returns_some() {
446 let mut config = IgnoreConfig::none();
447 config.set_defaults(true);
448 let fs = FakeFs(HashMap::new());
449
450 let filter = config.build_filter(Path::new("/"), &fs).await;
451 assert!(filter.is_some());
452 let filter = filter.unwrap();
453 assert!(filter.is_name_ignored("target", true));
455 assert!(filter.is_name_ignored("node_modules", true));
456 assert!(!filter.is_name_ignored("src", true));
457 }
458
459 #[tokio::test]
460 async fn test_build_filter_loads_gitignore() {
461 let mut config = IgnoreConfig::none();
462 config.add_file(".gitignore");
463
464 let mut files = HashMap::new();
465 files.insert(PathBuf::from("/project/.gitignore"), b"*.log\nbuild/\n".to_vec());
466 let fs = FakeFs(files);
467
468 let filter = config.build_filter(Path::new("/project"), &fs).await;
469 assert!(filter.is_some());
470 let filter = filter.unwrap();
471 assert!(filter.is_name_ignored("debug.log", false));
472 assert!(filter.is_name_ignored("build", true));
473 assert!(!filter.is_name_ignored("src", true));
474 }
475
476 #[tokio::test]
477 async fn test_build_filter_missing_file_skipped() {
478 let mut config = IgnoreConfig::none();
479 config.add_file(".gitignore");
480 config.add_file(".nonexistent");
481
482 let mut files = HashMap::new();
483 files.insert(PathBuf::from("/root/.gitignore"), b"*.tmp\n".to_vec());
484 let fs = FakeFs(files);
485
486 let filter = config.build_filter(Path::new("/root"), &fs).await;
488 assert!(filter.is_some());
489 let filter = filter.unwrap();
490 assert!(filter.is_name_ignored("test.tmp", false));
491 }
492
493 #[tokio::test]
494 async fn test_build_filter_defaults_plus_gitignore_merged() {
495 let config = IgnoreConfig::agent();
496
497 let mut files = HashMap::new();
498 files.insert(PathBuf::from("/project/.gitignore"), b"*.secret\n".to_vec());
499 let fs = FakeFs(files);
500
501 let filter = config.build_filter(Path::new("/project"), &fs).await;
502 assert!(filter.is_some());
503 let filter = filter.unwrap();
504 assert!(filter.is_name_ignored("target", true));
506 assert!(filter.is_name_ignored("node_modules", true));
507 assert!(filter.is_name_ignored("passwords.secret", false));
509 assert!(!filter.is_name_ignored("main.rs", false));
511 }
512
513 #[tokio::test]
522 async fn test_build_filter_parent_gitignore_walk_up() {
523 let mut config = IgnoreConfig::none();
524 config.add_file(".gitignore");
525
526 let mut files = HashMap::new();
527 files.insert(
528 PathBuf::from("/a/.gitignore"),
529 b"*.log\nb/secret.txt\n".to_vec(),
530 );
531 let fs = FakeFs(files);
532
533 let filter = config.build_filter(Path::new("/a/b"), &fs).await;
535 assert!(filter.is_some(), "filter should be loaded from ancestor");
536 let filter = filter.unwrap();
537
538 assert!(
540 filter.is_ignored(Path::new("debug.log"), false),
541 "ancestor's *.log must apply in subtree",
542 );
543 assert!(
544 filter.is_ignored(Path::new("nested/dir/app.log"), false),
545 "ancestor's *.log must reach nested files in subtree",
546 );
547
548 assert!(
551 filter.is_ignored(Path::new("secret.txt"), false),
552 "anchored ancestor rule must rebase to walker frame",
553 );
554
555 assert!(!filter.is_ignored(Path::new("main.rs"), false));
557 }
558
559 #[tokio::test]
564 async fn test_build_filter_dot_ignore_overrides_gitignore() {
565 let mut config = IgnoreConfig::none();
566 config.add_file(".gitignore");
568 config.add_file(".ignore");
569 config.add_file(".rgignore");
570
571 let mut files = HashMap::new();
572 files.insert(PathBuf::from("/proj/.gitignore"), b"*.log\n".to_vec());
573 files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
575 let fs = FakeFs(files);
576
577 let filter = config.build_filter(Path::new("/proj"), &fs).await;
578 assert!(filter.is_some());
579 let filter = filter.unwrap();
580
581 assert!(
582 filter.is_ignored(Path::new("debug.log"), false),
583 ".gitignore *.log still applies",
584 );
585 assert!(
586 !filter.is_ignored(Path::new("keep.log"), false),
587 ".ignore negation must override .gitignore",
588 );
589 }
590
591 #[tokio::test]
597 async fn test_build_filter_global_gitignore_honored() {
598 let tmp = tempfile::tempdir().expect("tempdir");
599 let global_path = tmp.path().join("git_ignore");
600 std::fs::write(&global_path, b"*.global_secret\n").expect("write global gitignore");
606
607 let mut config = IgnoreConfig::none();
608 config.set_use_global_gitignore(true);
609 config.set_global_gitignore_path(Some(global_path));
610
611 let fs = FakeFs(HashMap::new());
614
615 let filter = config.build_filter(Path::new("/proj"), &fs).await;
616 assert!(filter.is_some(), "global gitignore must activate filtering");
617 let filter = filter.unwrap();
618
619 assert!(
620 filter.is_ignored(Path::new("creds.global_secret"), false),
621 "global gitignore rule must apply",
622 );
623 assert!(!filter.is_ignored(Path::new("main.rs"), false));
624 }
625
626 #[tokio::test]
629 async fn test_build_filter_global_gitignore_missing_file_ok() {
630 let tmp = tempfile::tempdir().expect("tempdir");
631 let global_path = tmp.path().join("does_not_exist");
633
634 let mut config = IgnoreConfig::none();
635 config.set_use_global_gitignore(true);
636 config.set_global_gitignore_path(Some(global_path));
637
638 let fs = FakeFs(HashMap::new());
639 let filter = config.build_filter(Path::new("/proj"), &fs).await;
640 assert!(filter.is_some());
642 }
643
644 #[tokio::test]
646 async fn test_build_filter_rgignore_highest_precedence() {
647 let mut config = IgnoreConfig::none();
648 config.add_file(".gitignore");
649 config.add_file(".ignore");
650 config.add_file(".rgignore");
651
652 let mut files = HashMap::new();
653 files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
654 files.insert(PathBuf::from("/proj/.rgignore"), b"keep.log\n".to_vec());
656 let fs = FakeFs(files);
657
658 let filter = config.build_filter(Path::new("/proj"), &fs).await;
659 let filter = filter.unwrap();
660
661 assert!(
662 filter.is_ignored(Path::new("keep.log"), false),
663 ".rgignore must override .ignore",
664 );
665 }
666
667 #[tokio::test]
671 async fn test_build_filter_parent_anchored_rule_outside_subtree_dropped() {
672 let mut config = IgnoreConfig::none();
673 config.add_file(".gitignore");
674
675 let mut files = HashMap::new();
676 files.insert(PathBuf::from("/a/.gitignore"), b"c/foo.txt\n".to_vec());
677 let fs = FakeFs(files);
678
679 let filter = config.build_filter(Path::new("/a/b"), &fs).await;
682 assert!(filter.is_some());
683 let filter = filter.unwrap();
684
685 assert!(
688 !filter.is_ignored(Path::new("c/foo.txt"), false),
689 "anchored ancestor rule outside subtree must be dropped",
690 );
691 }
692 }
693}