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 let stripped = body.strip_prefix(&prefix_with_slash)?;
317 format!("/{stripped}")
318 };
319
320 let mut out = String::new();
321 if negated {
322 out.push('!');
323 }
324 out.push_str(&new_body);
325 if dir_only {
326 out.push('/');
327 }
328 Some(out)
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_none_is_inactive() {
337 let config = IgnoreConfig::none();
338 assert!(!config.is_active());
339 assert_eq!(config.scope(), IgnoreScope::Advisory);
340 assert!(!config.auto_gitignore());
341 }
342
343 #[test]
344 fn test_agent_preset_is_active() {
345 let config = IgnoreConfig::agent();
346 assert!(config.is_active());
347 assert_eq!(config.scope(), IgnoreScope::Enforced);
348 assert!(config.auto_gitignore());
349 assert!(config.use_defaults());
350 assert_eq!(config.files(), &[".gitignore"]);
351 }
352
353 #[test]
354 fn test_add_remove_files() {
355 let mut config = IgnoreConfig::none();
356 assert!(!config.is_active());
357
358 config.add_file(".dockerignore");
359 assert!(config.is_active());
360 assert_eq!(config.files(), &[".dockerignore"]);
361
362 config.add_file(".dockerignore");
364 assert_eq!(config.files().len(), 1);
365
366 config.remove_file(".dockerignore");
367 assert!(config.files().is_empty());
368 }
369
370 #[test]
371 fn test_clear() {
372 let mut config = IgnoreConfig::agent();
373 config.clear();
374 assert!(!config.is_active());
375 assert!(config.files().is_empty());
376 assert!(!config.use_defaults());
377 assert!(!config.auto_gitignore());
378 }
379
380 #[test]
381 fn test_set_scope() {
382 let mut config = IgnoreConfig::none();
383 config.set_scope(IgnoreScope::Enforced);
384 assert_eq!(config.scope(), IgnoreScope::Enforced);
385 }
386
387 #[test]
388 fn test_defaults_toggle() {
389 let mut config = IgnoreConfig::none();
390 config.set_defaults(true);
391 assert!(config.is_active());
392 config.set_defaults(false);
393 assert!(!config.is_active());
394 }
395
396 #[test]
397 fn test_auto_gitignore_alone_is_active() {
398 let mut config = IgnoreConfig::none();
399 assert!(!config.is_active());
400 config.set_auto_gitignore(true);
401 assert!(config.is_active());
402 }
403
404 mod async_tests {
405 use super::*;
406 use crate::walker::{WalkerDirEntry, WalkerError, WalkerFs};
407 use std::collections::HashMap;
408 use std::path::PathBuf;
409
410 struct MemEntry;
411 impl WalkerDirEntry for MemEntry {
412 fn name(&self) -> &str { "" }
413 fn is_dir(&self) -> bool { false }
414 fn is_file(&self) -> bool { true }
415 fn is_symlink(&self) -> bool { false }
416 }
417
418 struct FakeFs(HashMap<PathBuf, Vec<u8>>);
419
420 #[async_trait::async_trait]
421 impl WalkerFs for FakeFs {
422 type DirEntry = MemEntry;
423 async fn list_dir(&self, _: &Path) -> Result<Vec<MemEntry>, WalkerError> {
424 Ok(vec![])
425 }
426 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
427 self.0.get(path)
428 .cloned()
429 .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
430 }
431 async fn is_dir(&self, _: &Path) -> bool { false }
432 async fn exists(&self, path: &Path) -> bool { self.0.contains_key(path) }
433 }
434
435 #[tokio::test]
436 async fn test_build_filter_none_returns_none() {
437 let config = IgnoreConfig::none();
438 let fs = FakeFs(HashMap::new());
439 assert!(config.build_filter(Path::new("/"), &fs).await.is_none());
440 }
441
442 #[tokio::test]
443 async fn test_build_filter_defaults_returns_some() {
444 let mut config = IgnoreConfig::none();
445 config.set_defaults(true);
446 let fs = FakeFs(HashMap::new());
447
448 let filter = config.build_filter(Path::new("/"), &fs).await;
449 assert!(filter.is_some());
450 let filter = filter.unwrap();
451 assert!(filter.is_name_ignored("target", true));
453 assert!(filter.is_name_ignored("node_modules", true));
454 assert!(!filter.is_name_ignored("src", true));
455 }
456
457 #[tokio::test]
458 async fn test_build_filter_loads_gitignore() {
459 let mut config = IgnoreConfig::none();
460 config.add_file(".gitignore");
461
462 let mut files = HashMap::new();
463 files.insert(PathBuf::from("/project/.gitignore"), b"*.log\nbuild/\n".to_vec());
464 let fs = FakeFs(files);
465
466 let filter = config.build_filter(Path::new("/project"), &fs).await;
467 assert!(filter.is_some());
468 let filter = filter.unwrap();
469 assert!(filter.is_name_ignored("debug.log", false));
470 assert!(filter.is_name_ignored("build", true));
471 assert!(!filter.is_name_ignored("src", true));
472 }
473
474 #[tokio::test]
475 async fn test_build_filter_missing_file_skipped() {
476 let mut config = IgnoreConfig::none();
477 config.add_file(".gitignore");
478 config.add_file(".nonexistent");
479
480 let mut files = HashMap::new();
481 files.insert(PathBuf::from("/root/.gitignore"), b"*.tmp\n".to_vec());
482 let fs = FakeFs(files);
483
484 let filter = config.build_filter(Path::new("/root"), &fs).await;
486 assert!(filter.is_some());
487 let filter = filter.unwrap();
488 assert!(filter.is_name_ignored("test.tmp", false));
489 }
490
491 #[tokio::test]
492 async fn test_build_filter_defaults_plus_gitignore_merged() {
493 let config = IgnoreConfig::agent();
494
495 let mut files = HashMap::new();
496 files.insert(PathBuf::from("/project/.gitignore"), b"*.secret\n".to_vec());
497 let fs = FakeFs(files);
498
499 let filter = config.build_filter(Path::new("/project"), &fs).await;
500 assert!(filter.is_some());
501 let filter = filter.unwrap();
502 assert!(filter.is_name_ignored("target", true));
504 assert!(filter.is_name_ignored("node_modules", true));
505 assert!(filter.is_name_ignored("passwords.secret", false));
507 assert!(!filter.is_name_ignored("main.rs", false));
509 }
510
511 #[tokio::test]
520 async fn test_build_filter_parent_gitignore_walk_up() {
521 let mut config = IgnoreConfig::none();
522 config.add_file(".gitignore");
523
524 let mut files = HashMap::new();
525 files.insert(
526 PathBuf::from("/a/.gitignore"),
527 b"*.log\nb/secret.txt\n".to_vec(),
528 );
529 let fs = FakeFs(files);
530
531 let filter = config.build_filter(Path::new("/a/b"), &fs).await;
533 assert!(filter.is_some(), "filter should be loaded from ancestor");
534 let filter = filter.unwrap();
535
536 assert!(
538 filter.is_ignored(Path::new("debug.log"), false),
539 "ancestor's *.log must apply in subtree",
540 );
541 assert!(
542 filter.is_ignored(Path::new("nested/dir/app.log"), false),
543 "ancestor's *.log must reach nested files in subtree",
544 );
545
546 assert!(
549 filter.is_ignored(Path::new("secret.txt"), false),
550 "anchored ancestor rule must rebase to walker frame",
551 );
552
553 assert!(!filter.is_ignored(Path::new("main.rs"), false));
555 }
556
557 #[tokio::test]
562 async fn test_build_filter_dot_ignore_overrides_gitignore() {
563 let mut config = IgnoreConfig::none();
564 config.add_file(".gitignore");
566 config.add_file(".ignore");
567 config.add_file(".rgignore");
568
569 let mut files = HashMap::new();
570 files.insert(PathBuf::from("/proj/.gitignore"), b"*.log\n".to_vec());
571 files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
573 let fs = FakeFs(files);
574
575 let filter = config.build_filter(Path::new("/proj"), &fs).await;
576 assert!(filter.is_some());
577 let filter = filter.unwrap();
578
579 assert!(
580 filter.is_ignored(Path::new("debug.log"), false),
581 ".gitignore *.log still applies",
582 );
583 assert!(
584 !filter.is_ignored(Path::new("keep.log"), false),
585 ".ignore negation must override .gitignore",
586 );
587 }
588
589 #[tokio::test]
595 async fn test_build_filter_global_gitignore_honored() {
596 let tmp = tempfile::tempdir().expect("tempdir");
597 let global_path = tmp.path().join("git_ignore");
598 std::fs::write(&global_path, b"*.global_secret\n").expect("write global gitignore");
604
605 let mut config = IgnoreConfig::none();
606 config.set_use_global_gitignore(true);
607 config.set_global_gitignore_path(Some(global_path));
608
609 let fs = FakeFs(HashMap::new());
612
613 let filter = config.build_filter(Path::new("/proj"), &fs).await;
614 assert!(filter.is_some(), "global gitignore must activate filtering");
615 let filter = filter.unwrap();
616
617 assert!(
618 filter.is_ignored(Path::new("creds.global_secret"), false),
619 "global gitignore rule must apply",
620 );
621 assert!(!filter.is_ignored(Path::new("main.rs"), false));
622 }
623
624 #[tokio::test]
627 async fn test_build_filter_global_gitignore_missing_file_ok() {
628 let tmp = tempfile::tempdir().expect("tempdir");
629 let global_path = tmp.path().join("does_not_exist");
631
632 let mut config = IgnoreConfig::none();
633 config.set_use_global_gitignore(true);
634 config.set_global_gitignore_path(Some(global_path));
635
636 let fs = FakeFs(HashMap::new());
637 let filter = config.build_filter(Path::new("/proj"), &fs).await;
638 assert!(filter.is_some());
640 }
641
642 #[tokio::test]
644 async fn test_build_filter_rgignore_highest_precedence() {
645 let mut config = IgnoreConfig::none();
646 config.add_file(".gitignore");
647 config.add_file(".ignore");
648 config.add_file(".rgignore");
649
650 let mut files = HashMap::new();
651 files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
652 files.insert(PathBuf::from("/proj/.rgignore"), b"keep.log\n".to_vec());
654 let fs = FakeFs(files);
655
656 let filter = config.build_filter(Path::new("/proj"), &fs).await;
657 let filter = filter.unwrap();
658
659 assert!(
660 filter.is_ignored(Path::new("keep.log"), false),
661 ".rgignore must override .ignore",
662 );
663 }
664
665 #[tokio::test]
669 async fn test_build_filter_parent_anchored_rule_outside_subtree_dropped() {
670 let mut config = IgnoreConfig::none();
671 config.add_file(".gitignore");
672
673 let mut files = HashMap::new();
674 files.insert(PathBuf::from("/a/.gitignore"), b"c/foo.txt\n".to_vec());
675 let fs = FakeFs(files);
676
677 let filter = config.build_filter(Path::new("/a/b"), &fs).await;
680 assert!(filter.is_some());
681 let filter = filter.unwrap();
682
683 assert!(
686 !filter.is_ignored(Path::new("c/foo.txt"), false),
687 "anchored ancestor rule outside subtree must be dropped",
688 );
689 }
690 }
691}