fallow_config/config/
finding_ignore.rs1use globset::{Glob, GlobSet, GlobSetBuilder};
2
3#[derive(Debug, Clone, Default)]
9pub struct FindingIgnoreMatcher {
10 hidden: GlobSet,
11 reported: GlobSet,
12 has_patterns: bool,
13}
14
15impl FindingIgnoreMatcher {
16 #[expect(
17 clippy::expect_used,
18 reason = "ignoreFindings patterns are validated before config resolution"
19 )]
20 pub(crate) fn compile(patterns: &[String]) -> Self {
21 if patterns.is_empty() {
22 return Self::default();
23 }
24
25 let (hidden, reported) = Self::build_sets(patterns)
26 .expect("ignoreFindings pattern sets were validated before config resolution");
27
28 Self {
29 hidden,
30 reported,
31 has_patterns: true,
32 }
33 }
34
35 pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
36 Self::build_sets(patterns).map(|_| ())
37 }
38
39 fn build_sets(patterns: &[String]) -> Result<(GlobSet, GlobSet), globset::Error> {
40 let mut hidden = GlobSetBuilder::new();
41 let mut reported = GlobSetBuilder::new();
42
43 for pattern in patterns {
44 let (builder, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
45 (&mut reported, pattern)
46 } else {
47 (&mut hidden, pattern.as_str())
48 };
49 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
50 builder.add(Glob::new(pattern)?);
51 }
52
53 Ok((hidden.build()?, reported.build()?))
54 }
55
56 #[must_use]
58 pub const fn is_empty(&self) -> bool {
59 !self.has_patterns
60 }
61
62 #[must_use]
64 pub fn is_ignored(&self, path: &str) -> bool {
65 self.has_patterns
66 && (self.hidden.is_empty() || self.hidden.is_match(path))
67 && !self.reported.is_match(path)
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
76 FindingIgnoreMatcher::compile(
77 &patterns
78 .iter()
79 .map(|pattern| (*pattern).to_string())
80 .collect::<Vec<_>>(),
81 )
82 }
83
84 #[test]
85 fn empty_matcher_ignores_nothing() {
86 let matcher = matcher(&[]);
87
88 assert!(matcher.is_empty());
89 assert!(!matcher.is_ignored("src/app.ts"));
90 }
91
92 #[test]
93 fn positive_patterns_hide_matching_paths() {
94 let matcher = matcher(&["**/*.test.ts"]);
95
96 assert!(matcher.is_ignored("src/app.test.ts"));
97 assert!(!matcher.is_ignored("src/app.ts"));
98 }
99
100 #[test]
101 fn negated_patterns_keep_matching_paths_reported() {
102 let matcher = matcher(&["**/*.ts", "!src/public/**"]);
103
104 assert!(matcher.is_ignored("src/private/app.ts"));
105 assert!(!matcher.is_ignored("src/public/app.ts"));
106 }
107
108 #[test]
109 fn negated_only_patterns_report_only_matching_paths() {
110 let matcher = matcher(&["!src/public/**"]);
111
112 assert!(matcher.is_ignored("src/private/app.ts"));
113 assert!(!matcher.is_ignored("src/public/app.ts"));
114 }
115
116 #[test]
117 fn leading_dot_slash_is_normalized_after_negation() {
118 let matcher = matcher(&["!./src/public/**"]);
119
120 assert!(matcher.is_ignored("src/private/app.ts"));
121 assert!(!matcher.is_ignored("src/public/app.ts"));
122 }
123
124 #[test]
125 fn pattern_order_does_not_change_set_semantics() {
126 let first = matcher(&["**/*.ts", "!src/public/**"]);
127 let second = matcher(&["!src/public/**", "**/*.ts"]);
128
129 for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
130 assert_eq!(first.is_ignored(path), second.is_ignored(path));
131 }
132 }
133
134 #[test]
135 fn matches_dotfiles_and_forward_slash_paths() {
136 let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
137
138 assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
139 assert!(matcher.is_ignored(".storybook/preview.ts"));
140 }
141}