1use std::path::{Path, PathBuf};
8
9use crate::walker::{IgnoreFilter, WalkerFs};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum IgnoreScope {
14 Advisory,
17 Enforced,
20}
21
22#[derive(Debug, Clone)]
27pub struct IgnoreConfig {
28 scope: IgnoreScope,
29 ignore_files: Vec<String>,
30 use_defaults: bool,
31 auto_gitignore: bool,
32 use_global_gitignore: bool,
37 global_gitignore_path_override: Option<PathBuf>,
41}
42
43impl IgnoreConfig {
44 pub fn none() -> Self {
46 Self {
47 scope: IgnoreScope::Advisory,
48 ignore_files: Vec::new(),
49 use_defaults: false,
50 auto_gitignore: false,
51 use_global_gitignore: false,
52 global_gitignore_path_override: None,
53 }
54 }
55
56 pub fn mcp() -> Self {
58 Self {
59 scope: IgnoreScope::Enforced,
60 ignore_files: vec![".gitignore".to_string()],
61 use_defaults: true,
62 auto_gitignore: true,
63 use_global_gitignore: false,
64 global_gitignore_path_override: None,
65 }
66 }
67
68 pub fn is_active(&self) -> bool {
70 self.use_defaults
71 || self.auto_gitignore
72 || !self.ignore_files.is_empty()
73 || self.use_global_gitignore
74 }
75
76 pub fn scope(&self) -> IgnoreScope {
77 self.scope
78 }
79
80 pub fn auto_gitignore(&self) -> bool {
82 self.auto_gitignore
83 }
84
85 pub fn use_defaults(&self) -> bool {
86 self.use_defaults
87 }
88
89 pub fn files(&self) -> &[String] {
90 &self.ignore_files
91 }
92
93 pub fn set_scope(&mut self, scope: IgnoreScope) {
94 self.scope = scope;
95 }
96
97 pub fn set_defaults(&mut self, on: bool) {
98 self.use_defaults = on;
99 }
100
101 pub fn set_auto_gitignore(&mut self, on: bool) {
102 self.auto_gitignore = on;
103 }
104
105 pub fn set_use_global_gitignore(&mut self, on: bool) {
110 self.use_global_gitignore = on;
111 }
112
113 pub fn use_global_gitignore(&self) -> bool {
114 self.use_global_gitignore
115 }
116
117 pub fn set_global_gitignore_path(&mut self, path: Option<PathBuf>) {
120 self.global_gitignore_path_override = path;
121 }
122
123 pub fn add_file(&mut self, name: &str) {
124 if !self.ignore_files.iter().any(|f| f == name) {
125 self.ignore_files.push(name.to_string());
126 }
127 }
128
129 pub fn remove_file(&mut self, name: &str) {
130 self.ignore_files.retain(|f| f != name);
131 }
132
133 pub fn clear(&mut self) {
134 self.ignore_files.clear();
135 self.use_defaults = false;
136 self.auto_gitignore = false;
137 self.use_global_gitignore = false;
138 self.global_gitignore_path_override = None;
139 }
140
141 pub async fn build_filter<F: WalkerFs>(
155 &self,
156 root: &Path,
157 fs: &F,
158 ) -> Option<IgnoreFilter> {
159 if !self.is_active() {
160 return None;
161 }
162
163 let mut filter = if self.use_defaults {
164 IgnoreFilter::with_defaults()
165 } else {
166 IgnoreFilter::new()
167 };
168
169 if self.use_global_gitignore {
174 let path = self
175 .global_gitignore_path_override
176 .clone()
177 .or_else(ignore::gitignore::gitconfig_excludes_path);
178 if let Some(path) = path
179 && let Ok(content) = std::fs::read_to_string(&path)
180 {
181 for line in content.lines() {
182 filter.add_rule(line);
183 }
184 }
185 }
186
187 let mut ancestors: Vec<(PathBuf, String)> = Vec::new();
192 let mut current = root;
193 while let Some(parent) = current.parent() {
194 if let Ok(rel) = root.strip_prefix(parent) {
196 ancestors.push((
197 parent.to_path_buf(),
198 rel.to_string_lossy().into_owned(),
199 ));
200 }
201 if parent == current {
202 break;
203 }
204 current = parent;
205 }
206 ancestors.reverse(); for (ancestor_dir, prefix) in &ancestors {
209 for filename in &self.ignore_files {
210 let path = ancestor_dir.join(filename);
211 if !fs.exists(&path).await {
212 continue;
213 }
214 let Ok(bytes) = fs.read_file(&path).await else {
215 continue;
216 };
217 let text = String::from_utf8_lossy(&bytes);
218 for line in text.lines() {
219 if let Some(rebased) = rebase_gitignore_line(line, prefix) {
220 filter.add_rule(&rebased);
221 }
222 }
223 }
224 }
225
226 for filename in &self.ignore_files {
228 let path = root.join(filename);
229 if let Ok(file_filter) = IgnoreFilter::from_gitignore(&path, fs).await {
230 filter.merge(&file_filter);
231 }
232 }
234
235 Some(filter)
236 }
237}
238
239fn rebase_gitignore_line(line: &str, prefix: &str) -> Option<String> {
253 let trimmed = line.trim();
254 if trimmed.is_empty() || trimmed.starts_with('#') {
255 return None;
256 }
257
258 let (negated, rest) = if let Some(stripped) = trimmed.strip_prefix('!') {
260 (true, stripped)
261 } else {
262 (false, trimmed)
263 };
264
265 let (dir_only, rest) = if let Some(stripped) = rest.strip_suffix('/') {
267 (true, stripped)
268 } else {
269 (false, rest)
270 };
271
272 let leading_slash = rest.starts_with('/');
276 let body = rest.trim_start_matches('/');
277 let is_anchored = leading_slash || body.contains('/');
278
279 let prefix = prefix.trim_matches('/');
280
281 let new_body: String = if !is_anchored {
282 body.to_string()
284 } else if prefix.is_empty() {
285 format!("/{body}")
288 } else {
289 if body == prefix {
293 return None;
296 }
297 let prefix_with_slash = format!("{prefix}/");
298 match body.strip_prefix(&prefix_with_slash) {
299 Some(stripped) => format!("/{stripped}"),
300 None => return None,
301 }
302 };
303
304 let mut out = String::new();
305 if negated {
306 out.push('!');
307 }
308 out.push_str(&new_body);
309 if dir_only {
310 out.push('/');
311 }
312 Some(out)
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn test_none_is_inactive() {
321 let config = IgnoreConfig::none();
322 assert!(!config.is_active());
323 assert_eq!(config.scope(), IgnoreScope::Advisory);
324 assert!(!config.auto_gitignore());
325 }
326
327 #[test]
328 fn test_mcp_is_active() {
329 let config = IgnoreConfig::mcp();
330 assert!(config.is_active());
331 assert_eq!(config.scope(), IgnoreScope::Enforced);
332 assert!(config.auto_gitignore());
333 assert!(config.use_defaults());
334 assert_eq!(config.files(), &[".gitignore"]);
335 }
336
337 #[test]
338 fn test_add_remove_files() {
339 let mut config = IgnoreConfig::none();
340 assert!(!config.is_active());
341
342 config.add_file(".dockerignore");
343 assert!(config.is_active());
344 assert_eq!(config.files(), &[".dockerignore"]);
345
346 config.add_file(".dockerignore");
348 assert_eq!(config.files().len(), 1);
349
350 config.remove_file(".dockerignore");
351 assert!(config.files().is_empty());
352 }
353
354 #[test]
355 fn test_clear() {
356 let mut config = IgnoreConfig::mcp();
357 config.clear();
358 assert!(!config.is_active());
359 assert!(config.files().is_empty());
360 assert!(!config.use_defaults());
361 assert!(!config.auto_gitignore());
362 }
363
364 #[test]
365 fn test_set_scope() {
366 let mut config = IgnoreConfig::none();
367 config.set_scope(IgnoreScope::Enforced);
368 assert_eq!(config.scope(), IgnoreScope::Enforced);
369 }
370
371 #[test]
372 fn test_defaults_toggle() {
373 let mut config = IgnoreConfig::none();
374 config.set_defaults(true);
375 assert!(config.is_active());
376 config.set_defaults(false);
377 assert!(!config.is_active());
378 }
379
380 #[test]
381 fn test_auto_gitignore_alone_is_active() {
382 let mut config = IgnoreConfig::none();
383 assert!(!config.is_active());
384 config.set_auto_gitignore(true);
385 assert!(config.is_active());
386 }
387
388 mod async_tests {
389 use super::*;
390 use crate::walker::{WalkerDirEntry, WalkerError, WalkerFs};
391 use std::collections::HashMap;
392 use std::path::PathBuf;
393
394 struct MemEntry;
395 impl WalkerDirEntry for MemEntry {
396 fn name(&self) -> &str { "" }
397 fn is_dir(&self) -> bool { false }
398 fn is_file(&self) -> bool { true }
399 fn is_symlink(&self) -> bool { false }
400 }
401
402 struct FakeFs(HashMap<PathBuf, Vec<u8>>);
403
404 #[async_trait::async_trait]
405 impl WalkerFs for FakeFs {
406 type DirEntry = MemEntry;
407 async fn list_dir(&self, _: &Path) -> Result<Vec<MemEntry>, WalkerError> {
408 Ok(vec![])
409 }
410 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
411 self.0.get(path)
412 .cloned()
413 .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
414 }
415 async fn is_dir(&self, _: &Path) -> bool { false }
416 async fn exists(&self, path: &Path) -> bool { self.0.contains_key(path) }
417 }
418
419 #[tokio::test]
420 async fn test_build_filter_none_returns_none() {
421 let config = IgnoreConfig::none();
422 let fs = FakeFs(HashMap::new());
423 assert!(config.build_filter(Path::new("/"), &fs).await.is_none());
424 }
425
426 #[tokio::test]
427 async fn test_build_filter_defaults_returns_some() {
428 let mut config = IgnoreConfig::none();
429 config.set_defaults(true);
430 let fs = FakeFs(HashMap::new());
431
432 let filter = config.build_filter(Path::new("/"), &fs).await;
433 assert!(filter.is_some());
434 let filter = filter.unwrap();
435 assert!(filter.is_name_ignored("target", true));
437 assert!(filter.is_name_ignored("node_modules", true));
438 assert!(!filter.is_name_ignored("src", true));
439 }
440
441 #[tokio::test]
442 async fn test_build_filter_loads_gitignore() {
443 let mut config = IgnoreConfig::none();
444 config.add_file(".gitignore");
445
446 let mut files = HashMap::new();
447 files.insert(PathBuf::from("/project/.gitignore"), b"*.log\nbuild/\n".to_vec());
448 let fs = FakeFs(files);
449
450 let filter = config.build_filter(Path::new("/project"), &fs).await;
451 assert!(filter.is_some());
452 let filter = filter.unwrap();
453 assert!(filter.is_name_ignored("debug.log", false));
454 assert!(filter.is_name_ignored("build", true));
455 assert!(!filter.is_name_ignored("src", true));
456 }
457
458 #[tokio::test]
459 async fn test_build_filter_missing_file_skipped() {
460 let mut config = IgnoreConfig::none();
461 config.add_file(".gitignore");
462 config.add_file(".nonexistent");
463
464 let mut files = HashMap::new();
465 files.insert(PathBuf::from("/root/.gitignore"), b"*.tmp\n".to_vec());
466 let fs = FakeFs(files);
467
468 let filter = config.build_filter(Path::new("/root"), &fs).await;
470 assert!(filter.is_some());
471 let filter = filter.unwrap();
472 assert!(filter.is_name_ignored("test.tmp", false));
473 }
474
475 #[tokio::test]
476 async fn test_build_filter_defaults_plus_gitignore_merged() {
477 let config = IgnoreConfig::mcp();
478
479 let mut files = HashMap::new();
480 files.insert(PathBuf::from("/project/.gitignore"), b"*.secret\n".to_vec());
481 let fs = FakeFs(files);
482
483 let filter = config.build_filter(Path::new("/project"), &fs).await;
484 assert!(filter.is_some());
485 let filter = filter.unwrap();
486 assert!(filter.is_name_ignored("target", true));
488 assert!(filter.is_name_ignored("node_modules", true));
489 assert!(filter.is_name_ignored("passwords.secret", false));
491 assert!(!filter.is_name_ignored("main.rs", false));
493 }
494
495 #[tokio::test]
504 async fn test_build_filter_parent_gitignore_walk_up() {
505 let mut config = IgnoreConfig::none();
506 config.add_file(".gitignore");
507
508 let mut files = HashMap::new();
509 files.insert(
510 PathBuf::from("/a/.gitignore"),
511 b"*.log\nb/secret.txt\n".to_vec(),
512 );
513 let fs = FakeFs(files);
514
515 let filter = config.build_filter(Path::new("/a/b"), &fs).await;
517 assert!(filter.is_some(), "filter should be loaded from ancestor");
518 let filter = filter.unwrap();
519
520 assert!(
522 filter.is_ignored(Path::new("debug.log"), false),
523 "ancestor's *.log must apply in subtree",
524 );
525 assert!(
526 filter.is_ignored(Path::new("nested/dir/app.log"), false),
527 "ancestor's *.log must reach nested files in subtree",
528 );
529
530 assert!(
533 filter.is_ignored(Path::new("secret.txt"), false),
534 "anchored ancestor rule must rebase to walker frame",
535 );
536
537 assert!(!filter.is_ignored(Path::new("main.rs"), false));
539 }
540
541 #[tokio::test]
546 async fn test_build_filter_dot_ignore_overrides_gitignore() {
547 let mut config = IgnoreConfig::none();
548 config.add_file(".gitignore");
550 config.add_file(".ignore");
551 config.add_file(".rgignore");
552
553 let mut files = HashMap::new();
554 files.insert(PathBuf::from("/proj/.gitignore"), b"*.log\n".to_vec());
555 files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
557 let fs = FakeFs(files);
558
559 let filter = config.build_filter(Path::new("/proj"), &fs).await;
560 assert!(filter.is_some());
561 let filter = filter.unwrap();
562
563 assert!(
564 filter.is_ignored(Path::new("debug.log"), false),
565 ".gitignore *.log still applies",
566 );
567 assert!(
568 !filter.is_ignored(Path::new("keep.log"), false),
569 ".ignore negation must override .gitignore",
570 );
571 }
572
573 #[tokio::test]
579 async fn test_build_filter_global_gitignore_honored() {
580 let tmp = tempfile::tempdir().expect("tempdir");
581 let global_path = tmp.path().join("git_ignore");
582 tokio::fs::write(&global_path, b"*.global_secret\n")
583 .await
584 .expect("write global gitignore");
585
586 let mut config = IgnoreConfig::none();
587 config.set_use_global_gitignore(true);
588 config.set_global_gitignore_path(Some(global_path));
589
590 let fs = FakeFs(HashMap::new());
593
594 let filter = config.build_filter(Path::new("/proj"), &fs).await;
595 assert!(filter.is_some(), "global gitignore must activate filtering");
596 let filter = filter.unwrap();
597
598 assert!(
599 filter.is_ignored(Path::new("creds.global_secret"), false),
600 "global gitignore rule must apply",
601 );
602 assert!(!filter.is_ignored(Path::new("main.rs"), false));
603 }
604
605 #[tokio::test]
608 async fn test_build_filter_global_gitignore_missing_file_ok() {
609 let tmp = tempfile::tempdir().expect("tempdir");
610 let global_path = tmp.path().join("does_not_exist");
612
613 let mut config = IgnoreConfig::none();
614 config.set_use_global_gitignore(true);
615 config.set_global_gitignore_path(Some(global_path));
616
617 let fs = FakeFs(HashMap::new());
618 let filter = config.build_filter(Path::new("/proj"), &fs).await;
619 assert!(filter.is_some());
621 }
622
623 #[tokio::test]
625 async fn test_build_filter_rgignore_highest_precedence() {
626 let mut config = IgnoreConfig::none();
627 config.add_file(".gitignore");
628 config.add_file(".ignore");
629 config.add_file(".rgignore");
630
631 let mut files = HashMap::new();
632 files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
633 files.insert(PathBuf::from("/proj/.rgignore"), b"keep.log\n".to_vec());
635 let fs = FakeFs(files);
636
637 let filter = config.build_filter(Path::new("/proj"), &fs).await;
638 let filter = filter.unwrap();
639
640 assert!(
641 filter.is_ignored(Path::new("keep.log"), false),
642 ".rgignore must override .ignore",
643 );
644 }
645
646 #[tokio::test]
650 async fn test_build_filter_parent_anchored_rule_outside_subtree_dropped() {
651 let mut config = IgnoreConfig::none();
652 config.add_file(".gitignore");
653
654 let mut files = HashMap::new();
655 files.insert(PathBuf::from("/a/.gitignore"), b"c/foo.txt\n".to_vec());
656 let fs = FakeFs(files);
657
658 let filter = config.build_filter(Path::new("/a/b"), &fs).await;
661 assert!(filter.is_some());
662 let filter = filter.unwrap();
663
664 assert!(
667 !filter.is_ignored(Path::new("c/foo.txt"), false),
668 "anchored ancestor rule outside subtree must be dropped",
669 );
670 }
671 }
672}