use std::collections::BTreeMap;
use std::path::Path;
use globset::{Glob, GlobSet, GlobSetBuilder};
use diffguard_types::Severity;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectoryRuleOverride {
pub directory: String,
pub rule_id: String,
pub enabled: Option<bool>,
pub severity: Option<Severity>,
pub exclude_paths: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum OverrideCompileError {
#[error("rule override '{rule_id}' in '{directory}' has invalid glob '{glob}': {source}")]
InvalidGlob {
rule_id: String,
directory: String,
glob: String,
source: globset::Error,
},
}
#[derive(Debug, Clone)]
struct CompiledDirectoryRuleOverride {
directory: String,
depth: usize,
enabled: Option<bool>,
severity: Option<Severity>,
exclude: Option<GlobSet>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedRuleOverride {
pub enabled: bool,
pub severity: Option<Severity>,
}
impl Default for ResolvedRuleOverride {
fn default() -> Self {
Self {
enabled: true,
severity: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RuleOverrideMatcher {
by_rule: BTreeMap<String, Vec<CompiledDirectoryRuleOverride>>,
}
impl RuleOverrideMatcher {
pub fn compile(specs: &[DirectoryRuleOverride]) -> Result<Self, OverrideCompileError> {
let mut by_rule: BTreeMap<String, Vec<CompiledDirectoryRuleOverride>> = BTreeMap::new();
for spec in specs {
let directory = normalize_directory(&spec.directory);
let exclude = compile_exclude_globs(&directory, &spec.rule_id, &spec.exclude_paths)?;
by_rule
.entry(spec.rule_id.clone())
.or_default()
.push(CompiledDirectoryRuleOverride {
depth: directory_depth(&directory),
directory,
enabled: spec.enabled,
severity: spec.severity,
exclude,
});
}
for entries in by_rule.values_mut() {
entries.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then_with(|| a.directory.cmp(&b.directory))
});
}
Ok(Self { by_rule })
}
pub fn resolve(&self, path: &str, rule_id: &str) -> ResolvedRuleOverride {
let Some(entries) = self.by_rule.get(rule_id) else {
return ResolvedRuleOverride::default();
};
let mut resolved = ResolvedRuleOverride::default();
let normalized_path = normalize_path(path);
let path_ref = Path::new(&normalized_path);
for entry in entries {
if !path_in_directory(&normalized_path, &entry.directory) {
continue;
}
if let Some(enabled) = entry.enabled {
resolved.enabled = enabled;
}
if let Some(severity) = entry.severity {
resolved.severity = Some(severity);
}
if entry
.exclude
.as_ref()
.is_some_and(|exclude| exclude.is_match(path_ref))
{
resolved.enabled = false;
}
}
resolved
}
}
fn normalize_path(path: &str) -> String {
let replaced = path.replace('\\', "/");
let without_dot = replaced.strip_prefix("./").unwrap_or(&replaced);
without_dot.trim_start_matches('/').to_string()
}
fn normalize_directory(directory: &str) -> String {
let normalized = normalize_path(directory);
if normalized.is_empty() || normalized == "." {
return String::new();
}
normalized.trim_end_matches('/').to_string()
}
fn directory_depth(directory: &str) -> usize {
if directory.is_empty() {
0
} else {
directory.split('/').filter(|s| !s.is_empty()).count()
}
}
fn path_in_directory(path: &str, directory: &str) -> bool {
if directory.is_empty() {
return true;
}
if path == directory {
return true;
}
path.starts_with(directory) && path.as_bytes().get(directory.len()) == Some(&b'/')
}
fn compile_exclude_globs(
directory: &str,
rule_id: &str,
globs: &[String],
) -> Result<Option<GlobSet>, OverrideCompileError> {
if globs.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for glob in globs {
let scoped = scope_glob_to_directory(directory, glob);
let parsed = Glob::new(&scoped).map_err(|source| OverrideCompileError::InvalidGlob {
rule_id: rule_id.to_string(),
directory: directory.to_string(),
glob: scoped.clone(),
source,
})?;
builder.add(parsed);
}
Ok(Some(builder.build().expect("globset build should succeed")))
}
fn scope_glob_to_directory(directory: &str, glob: &str) -> String {
let replaced = glob.replace('\\', "/");
let without_dot = replaced.strip_prefix("./").unwrap_or(&replaced);
if directory.is_empty() || without_dot.starts_with('/') {
without_dot.trim_start_matches('/').to_string()
} else {
format!("{}/{}", directory, without_dot.trim_start_matches('/'))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn override_spec(
directory: &str,
rule_id: &str,
enabled: Option<bool>,
severity: Option<Severity>,
exclude_paths: Vec<&str>,
) -> DirectoryRuleOverride {
DirectoryRuleOverride {
directory: directory.to_string(),
rule_id: rule_id.to_string(),
enabled,
severity,
exclude_paths: exclude_paths.into_iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn parent_and_child_overrides_merge_in_depth_order() {
let matcher = RuleOverrideMatcher::compile(&[
override_spec("src", "rust.no_unwrap", Some(false), None, vec![]),
override_spec(
"src/legacy",
"rust.no_unwrap",
Some(true),
Some(Severity::Warn),
vec![],
),
])
.expect("compile overrides");
let parent_only = matcher.resolve("src/new/mod.rs", "rust.no_unwrap");
assert!(!parent_only.enabled);
assert_eq!(parent_only.severity, None);
let child = matcher.resolve("src/legacy/mod.rs", "rust.no_unwrap");
assert!(child.enabled);
assert_eq!(child.severity, Some(Severity::Warn));
}
#[test]
fn exclude_paths_are_scoped_to_override_directory() {
let matcher = RuleOverrideMatcher::compile(&[override_spec(
"src",
"rust.no_unwrap",
None,
None,
vec!["**/generated/**"],
)])
.expect("compile overrides");
assert!(
!matcher
.resolve("src/generated/file.rs", "rust.no_unwrap")
.enabled
);
assert!(
matcher
.resolve("generated/file.rs", "rust.no_unwrap")
.enabled
);
}
#[test]
fn root_directory_override_applies_everywhere() {
let matcher = RuleOverrideMatcher::compile(&[override_spec(
"",
"rust.no_unwrap",
Some(false),
None,
vec![],
)])
.expect("compile overrides");
assert!(!matcher.resolve("src/lib.rs", "rust.no_unwrap").enabled);
assert!(!matcher.resolve("main.rs", "rust.no_unwrap").enabled);
}
#[test]
fn invalid_override_glob_returns_error() {
let err = RuleOverrideMatcher::compile(&[override_spec(
"src",
"rust.no_unwrap",
None,
None,
vec!["["],
)])
.expect_err("invalid glob should fail");
match err {
OverrideCompileError::InvalidGlob { glob, .. } => {
assert_eq!(glob, "src/[");
}
}
}
}