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