Skip to main content

diffguard_domain/
overrides.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5
6use diffguard_types::Severity;
7
8/// A per-directory rule override loaded from `.diffguard.toml`.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct DirectoryRuleOverride {
11    /// Directory path (repo-relative). Empty string means repo root.
12    pub directory: String,
13    /// Rule identifier to override.
14    pub rule_id: String,
15    /// Optional enabled/disabled flag.
16    pub enabled: Option<bool>,
17    /// Optional severity override for files in scope.
18    pub severity: Option<Severity>,
19    /// Additional exclude globs scoped to this directory.
20    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/// Resolved effective override state for a single (path, rule) pair.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct ResolvedRuleOverride {
46    /// Whether this rule is enabled for the path.
47    pub enabled: bool,
48    /// Optional severity override.
49    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/// Matcher for per-directory overrides.
62///
63/// Overrides are applied from shallowest to deepest directory so child
64/// directories can refine parent behavior.
65#[derive(Debug, Clone, Default)]
66pub struct RuleOverrideMatcher {
67    by_rule: BTreeMap<String, Vec<CompiledDirectoryRuleOverride>>,
68}
69
70impl RuleOverrideMatcher {
71    /// Compile raw directory overrides into a matcher.
72    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    /// Resolve the effective override for a specific path and rule id.
103    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}