1use std::collections::BTreeMap;
2use std::path::Path;
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5
6use diffguard_types::Severity;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct DirectoryRuleOverride {
11 pub directory: String,
13 pub rule_id: String,
15 pub enabled: Option<bool>,
17 pub severity: Option<Severity>,
19 pub exclude_paths: Vec<String>,
21}
22
23#[derive(Debug, thiserror::Error)]
24pub enum OverrideCompileError {
25 #[error("rule override '{rule_id}' in '{directory}' has invalid glob '{glob}': {source}")]
26 InvalidGlob {
27 rule_id: String,
28 directory: String,
29 glob: String,
30 source: globset::Error,
31 },
32}
33
34#[derive(Debug, Clone)]
35struct CompiledDirectoryRuleOverride {
36 directory: String,
37 depth: usize,
38 enabled: Option<bool>,
39 severity: Option<Severity>,
40 exclude: Option<GlobSet>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct ResolvedRuleOverride {
46 pub enabled: bool,
48 pub severity: Option<Severity>,
50}
51
52impl Default for ResolvedRuleOverride {
53 fn default() -> Self {
54 Self {
55 enabled: true,
56 severity: None,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Default)]
66pub struct RuleOverrideMatcher {
67 by_rule: BTreeMap<String, Vec<CompiledDirectoryRuleOverride>>,
68}
69
70impl RuleOverrideMatcher {
71 pub fn compile(specs: &[DirectoryRuleOverride]) -> Result<Self, OverrideCompileError> {
73 let mut by_rule: BTreeMap<String, Vec<CompiledDirectoryRuleOverride>> = BTreeMap::new();
74
75 for spec in specs {
76 let directory = normalize_directory(&spec.directory);
77 let exclude = compile_exclude_globs(&directory, &spec.rule_id, &spec.exclude_paths)?;
78
79 by_rule
80 .entry(spec.rule_id.clone())
81 .or_default()
82 .push(CompiledDirectoryRuleOverride {
83 depth: directory_depth(&directory),
84 directory,
85 enabled: spec.enabled,
86 severity: spec.severity,
87 exclude,
88 });
89 }
90
91 for entries in by_rule.values_mut() {
92 entries.sort_by(|a, b| {
93 a.depth
94 .cmp(&b.depth)
95 .then_with(|| a.directory.cmp(&b.directory))
96 });
97 }
98
99 Ok(Self { by_rule })
100 }
101
102 pub fn resolve(&self, path: &str, rule_id: &str) -> ResolvedRuleOverride {
104 let Some(entries) = self.by_rule.get(rule_id) else {
105 return ResolvedRuleOverride::default();
106 };
107
108 let mut resolved = ResolvedRuleOverride::default();
109 let normalized_path = normalize_path(path);
110 let path_ref = Path::new(&normalized_path);
111
112 for entry in entries {
113 if !path_in_directory(&normalized_path, &entry.directory) {
114 continue;
115 }
116
117 if let Some(enabled) = entry.enabled {
118 resolved.enabled = enabled;
119 }
120
121 if let Some(severity) = entry.severity {
122 resolved.severity = Some(severity);
123 }
124
125 if entry
126 .exclude
127 .as_ref()
128 .is_some_and(|exclude| exclude.is_match(path_ref))
129 {
130 resolved.enabled = false;
131 }
132 }
133
134 resolved
135 }
136}
137
138fn normalize_path(path: &str) -> String {
139 let replaced = path.replace('\\', "/");
140 let without_dot = replaced.strip_prefix("./").unwrap_or(&replaced);
141 without_dot.trim_start_matches('/').to_string()
142}
143
144fn normalize_directory(directory: &str) -> String {
145 let normalized = normalize_path(directory);
146 if normalized.is_empty() || normalized == "." {
147 return String::new();
148 }
149 normalized.trim_end_matches('/').to_string()
150}
151
152fn directory_depth(directory: &str) -> usize {
153 if directory.is_empty() {
154 0
155 } else {
156 directory.split('/').filter(|s| !s.is_empty()).count()
157 }
158}
159
160fn path_in_directory(path: &str, directory: &str) -> bool {
161 if directory.is_empty() {
162 return true;
163 }
164 if path == directory {
165 return true;
166 }
167 path.starts_with(directory) && path.as_bytes().get(directory.len()) == Some(&b'/')
168}
169
170fn compile_exclude_globs(
171 directory: &str,
172 rule_id: &str,
173 globs: &[String],
174) -> Result<Option<GlobSet>, OverrideCompileError> {
175 if globs.is_empty() {
176 return Ok(None);
177 }
178
179 let mut builder = GlobSetBuilder::new();
180 for glob in globs {
181 let scoped = scope_glob_to_directory(directory, glob);
182 let parsed = Glob::new(&scoped).map_err(|source| OverrideCompileError::InvalidGlob {
183 rule_id: rule_id.to_string(),
184 directory: directory.to_string(),
185 glob: scoped.clone(),
186 source,
187 })?;
188 builder.add(parsed);
189 }
190
191 Ok(Some(builder.build().expect("globset build should succeed")))
192}
193
194fn scope_glob_to_directory(directory: &str, glob: &str) -> String {
195 let replaced = glob.replace('\\', "/");
196 let without_dot = replaced.strip_prefix("./").unwrap_or(&replaced);
197
198 if directory.is_empty() || without_dot.starts_with('/') {
199 without_dot.trim_start_matches('/').to_string()
200 } else {
201 format!("{}/{}", directory, without_dot.trim_start_matches('/'))
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn override_spec(
210 directory: &str,
211 rule_id: &str,
212 enabled: Option<bool>,
213 severity: Option<Severity>,
214 exclude_paths: Vec<&str>,
215 ) -> DirectoryRuleOverride {
216 DirectoryRuleOverride {
217 directory: directory.to_string(),
218 rule_id: rule_id.to_string(),
219 enabled,
220 severity,
221 exclude_paths: exclude_paths.into_iter().map(|s| s.to_string()).collect(),
222 }
223 }
224
225 #[test]
226 fn parent_and_child_overrides_merge_in_depth_order() {
227 let matcher = RuleOverrideMatcher::compile(&[
228 override_spec("src", "rust.no_unwrap", Some(false), None, vec![]),
229 override_spec(
230 "src/legacy",
231 "rust.no_unwrap",
232 Some(true),
233 Some(Severity::Warn),
234 vec![],
235 ),
236 ])
237 .expect("compile overrides");
238
239 let parent_only = matcher.resolve("src/new/mod.rs", "rust.no_unwrap");
240 assert!(!parent_only.enabled);
241 assert_eq!(parent_only.severity, None);
242
243 let child = matcher.resolve("src/legacy/mod.rs", "rust.no_unwrap");
244 assert!(child.enabled);
245 assert_eq!(child.severity, Some(Severity::Warn));
246 }
247
248 #[test]
249 fn exclude_paths_are_scoped_to_override_directory() {
250 let matcher = RuleOverrideMatcher::compile(&[override_spec(
251 "src",
252 "rust.no_unwrap",
253 None,
254 None,
255 vec!["**/generated/**"],
256 )])
257 .expect("compile overrides");
258
259 assert!(
260 !matcher
261 .resolve("src/generated/file.rs", "rust.no_unwrap")
262 .enabled
263 );
264 assert!(
265 matcher
266 .resolve("generated/file.rs", "rust.no_unwrap")
267 .enabled
268 );
269 }
270
271 #[test]
272 fn root_directory_override_applies_everywhere() {
273 let matcher = RuleOverrideMatcher::compile(&[override_spec(
274 "",
275 "rust.no_unwrap",
276 Some(false),
277 None,
278 vec![],
279 )])
280 .expect("compile overrides");
281
282 assert!(!matcher.resolve("src/lib.rs", "rust.no_unwrap").enabled);
283 assert!(!matcher.resolve("main.rs", "rust.no_unwrap").enabled);
284 }
285
286 #[test]
287 fn invalid_override_glob_returns_error() {
288 let err = RuleOverrideMatcher::compile(&[override_spec(
289 "src",
290 "rust.no_unwrap",
291 None,
292 None,
293 vec!["["],
294 )])
295 .expect_err("invalid glob should fail");
296
297 match err {
298 OverrideCompileError::InvalidGlob { glob, .. } => {
299 assert_eq!(glob, "src/[");
300 }
301 }
302 }
303}