use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
#[default]
Warning,
Info,
Hint,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Error => write!(f, "error"),
Severity::Warning => write!(f, "warning"),
Severity::Info => write!(f, "info"),
Severity::Hint => write!(f, "hint"),
}
}
}
impl std::str::FromStr for Severity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"error" => Ok(Severity::Error),
"warning" | "warn" => Ok(Severity::Warning),
"info" | "note" => Ok(Severity::Info),
"hint" => Ok(Severity::Hint),
_ => Err(format!("unknown severity: {}", s)),
}
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default, schemars::JsonSchema)]
#[serde(default)]
pub struct SarifTool {
pub name: String,
pub command: Vec<String>,
#[serde(default)]
pub watch: Vec<String>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default, schemars::JsonSchema)]
#[serde(default)]
pub struct RuleOverride {
pub severity: Option<String>,
pub enabled: Option<bool>,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(flatten)]
#[schemars(skip)]
pub extra: std::collections::HashMap<String, toml::Value>,
}
pub fn deserialize_one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(s) => Ok(vec![s]),
OneOrMany::Many(v) => Ok(v),
}
}
impl normalize_core::Merge for RuleOverride {
fn merge(self, other: Self) -> Self {
let mut extra = self.extra;
extra.extend(other.extra);
Self {
severity: other.severity.or(self.severity),
enabled: other.enabled.or(self.enabled),
allow: if other.allow.is_empty() {
self.allow
} else {
other.allow
},
tags: if other.tags.is_empty() {
self.tags
} else {
other.tags
},
extra,
}
}
}
impl RuleOverride {
pub fn rule_config<T: serde::de::DeserializeOwned + Default>(&self) -> T {
let table = toml::Value::Table(
self.extra
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
table.try_into().unwrap_or_default()
}
}
#[derive(Debug, Clone, serde::Serialize, Default, schemars::JsonSchema)]
pub struct RulesConfig {
#[serde(
rename = "global-allow",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub global_allow: Vec<String>,
#[serde(rename = "sarif-tools", default, skip_serializing_if = "Vec::is_empty")]
pub sarif_tools: Vec<SarifTool>,
#[serde(default, rename = "rule", skip_serializing_if = "HashMap::is_empty")]
pub rules: HashMap<String, RuleOverride>,
}
const RULES_RESERVED_KEYS: &[&str] = &["global-allow", "sarif-tools", "rule"];
impl<'de> serde::Deserialize<'de> for RulesConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: HashMap<String, toml::Value> = HashMap::deserialize(deserializer)?;
let mut global_allow: Vec<String> = Vec::new();
let mut sarif_tools: Vec<SarifTool> = Vec::new();
let mut rules: HashMap<String, RuleOverride> = HashMap::new();
let mut legacy_rule_ids: Vec<String> = Vec::new();
for (key, value) in raw {
match key.as_str() {
"global-allow" => {
global_allow = value.try_into().map_err(serde::de::Error::custom)?;
}
"sarif-tools" => {
sarif_tools = value.try_into().map_err(serde::de::Error::custom)?;
}
"rule" => {
let nested: HashMap<String, RuleOverride> =
value.try_into().map_err(serde::de::Error::custom)?;
rules.extend(nested);
}
_ => {
let override_: RuleOverride =
value.try_into().map_err(serde::de::Error::custom)?;
legacy_rule_ids.push(key.clone());
rules.entry(key).or_insert(override_);
}
}
}
if !legacy_rule_ids.is_empty() {
legacy_rule_ids.sort();
eprintln!(
"warning: deprecated [rules.\"<id>\"] layout in .normalize/config.toml — \
migrate to [rules.rule.\"<id>\"] (affected rule ids: {}). \
The legacy layout will be removed in a future release.",
legacy_rule_ids.join(", "),
);
}
let _ = RULES_RESERVED_KEYS;
Ok(RulesConfig {
global_allow,
sarif_tools,
rules,
})
}
}
impl normalize_core::Merge for RulesConfig {
fn merge(self, other: Self) -> Self {
let global_allow = if other.global_allow.is_empty() {
self.global_allow
} else {
other.global_allow
};
let sarif_tools = if other.sarif_tools.is_empty() {
self.sarif_tools
} else {
other.sarif_tools
};
let mut merged_rules = self.rules;
merged_rules.extend(other.rules);
Self {
global_allow,
sarif_tools,
rules: merged_rules,
}
}
}
#[derive(
Debug,
Clone,
serde::Deserialize,
serde::Serialize,
Default,
schemars::JsonSchema,
normalize_core::Merge,
)]
#[serde(default)]
pub struct WalkConfig {
pub ignore_files: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
}
impl WalkConfig {
pub fn ignore_files(&self) -> Vec<&str> {
match &self.ignore_files {
Some(v) => v.iter().map(|s| s.as_str()).collect(),
None => vec![".gitignore"],
}
}
pub fn exclude(&self) -> Vec<&str> {
match &self.exclude {
Some(v) => v.iter().map(|s| s.as_str()).collect(),
None => Vec::new(),
}
}
pub fn compiled_excludes(&self, root: &Path) -> ignore::gitignore::Gitignore {
let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
for pat in self.exclude() {
let _ = builder.add_line(None, pat);
}
builder.build().unwrap_or_else(|_| {
ignore::gitignore::Gitignore::empty()
})
}
pub fn is_excluded_path(&self, root: &Path, rel_path: &Path, is_dir: bool) -> bool {
let gi = self.compiled_excludes(root);
gi.matched_path_or_any_parents(rel_path, is_dir).is_ignore()
}
}
#[derive(Debug, Clone, Default)]
pub struct PathFilter {
pub only: Vec<glob::Pattern>,
pub exclude: Vec<glob::Pattern>,
}
impl PathFilter {
pub fn new(only: &[String], exclude: &[String]) -> Self {
Self {
only: only
.iter()
.filter_map(|s| glob::Pattern::new(s).ok())
.collect(),
exclude: exclude
.iter()
.filter_map(|s| glob::Pattern::new(s).ok())
.collect(),
}
}
pub fn is_empty(&self) -> bool {
self.only.is_empty() && self.exclude.is_empty()
}
pub fn matches(&self, rel_path: &str) -> bool {
if !self.exclude.is_empty() && self.exclude.iter().any(|p| p.matches(rel_path)) {
return false;
}
if !self.only.is_empty() && !self.only.iter().any(|p| p.matches(rel_path)) {
return false;
}
true
}
pub fn matches_path(&self, rel_path: &Path) -> bool {
self.matches(&rel_path.to_string_lossy())
}
}
#[derive(Debug, Default, Clone)]
pub struct ConfigDiff {
pub rules_to_rerun: HashSet<String>,
pub rules_disabled: HashSet<String>,
pub allow_lists_changed: bool,
pub severities_changed: bool,
pub walk_exclude_changed: bool,
}
impl ConfigDiff {
pub fn compute(
old_rules: &RulesConfig,
new_rules: &RulesConfig,
old_walk: &WalkConfig,
new_walk: &WalkConfig,
) -> Self {
let mut diff = ConfigDiff::default();
if old_walk.exclude() != new_walk.exclude() {
diff.walk_exclude_changed = true;
}
if old_rules.global_allow != new_rules.global_allow {
diff.allow_lists_changed = true;
}
let ids: HashSet<&str> = old_rules
.rules
.keys()
.chain(new_rules.rules.keys())
.map(String::as_str)
.collect();
for id in ids {
let old = old_rules.rules.get(id);
let new = new_rules.rules.get(id);
let was_enabled = old.is_none_or(|o| o.enabled.unwrap_or(true));
let is_enabled = new.is_none_or(|n| n.enabled.unwrap_or(true));
match (was_enabled, is_enabled) {
(true, false) => {
diff.rules_disabled.insert(id.to_string());
}
(false, true) => {
diff.rules_to_rerun.insert(id.to_string());
}
_ => {}
}
if was_enabled && is_enabled {
let old_sev = old.and_then(|o| o.severity.as_deref());
let new_sev = new.and_then(|n| n.severity.as_deref());
if old_sev != new_sev {
diff.severities_changed = true;
}
let old_allow = old.map(|o| o.allow.as_slice()).unwrap_or(&[]);
let new_allow = new.map(|n| n.allow.as_slice()).unwrap_or(&[]);
if old_allow != new_allow {
diff.allow_lists_changed = true;
}
let old_extra = old.map(|o| &o.extra);
let new_extra = new.map(|n| &n.extra);
if old_extra != new_extra {
diff.rules_to_rerun.insert(id.to_string());
}
let old_tags = old.map(|o| o.tags.as_slice()).unwrap_or(&[]);
let new_tags = new.map(|n| n.tags.as_slice()).unwrap_or(&[]);
if old_tags != new_tags {
diff.allow_lists_changed = true;
}
}
}
if old_rules.sarif_tools.len() != new_rules.sarif_tools.len() {
diff.rules_to_rerun
.insert("__sarif_tools_changed__".to_string());
} else {
for (a, b) in old_rules
.sarif_tools
.iter()
.zip(new_rules.sarif_tools.iter())
{
if a.name != b.name || a.command != b.command || a.watch != b.watch {
diff.rules_to_rerun
.insert("__sarif_tools_changed__".to_string());
break;
}
}
}
diff
}
pub fn is_filter_only(&self) -> bool {
self.rules_to_rerun.is_empty() && !self.walk_exclude_changed
}
pub fn requires_full_reprime(&self) -> bool {
self.walk_exclude_changed
}
pub fn is_empty(&self) -> bool {
self.rules_to_rerun.is_empty()
&& self.rules_disabled.is_empty()
&& !self.allow_lists_changed
&& !self.severities_changed
&& !self.walk_exclude_changed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allow_field_not_swallowed_by_extra() {
let toml_str = r#"
global-allow = ["**/fixtures/**"]
[rule."no-grammar-loader-new"]
allow = ["**/tests/**", "src/lib.rs"]
threshold = 42
"#;
let config: RulesConfig = toml::from_str(toml_str).unwrap();
let rule = config.rules.get("no-grammar-loader-new").unwrap();
assert_eq!(rule.allow, vec!["**/tests/**", "src/lib.rs"]);
assert!(!rule.extra.contains_key("allow"));
assert!(rule.extra.contains_key("threshold"));
}
#[test]
fn full_config_round_trip() {
let toml_str = r#"
global-allow = ["**/tests/fixtures/**", "**/fixtures/**", ".claude/**"]
[rule."rust/dbg-macro"]
severity = "error"
allow = ["**/tests/fixtures/**"]
[rule."no-grammar-loader-new"]
allow = ["**/tests/**", "crates/*/tests/**", "**/normalize-scope/**"]
"#;
let config: RulesConfig = toml::from_str(toml_str).unwrap();
let dbg = config.rules.get("rust/dbg-macro").unwrap();
assert_eq!(dbg.severity.as_deref(), Some("error"));
assert_eq!(dbg.allow, vec!["**/tests/fixtures/**"]);
let ngl = config.rules.get("no-grammar-loader-new").unwrap();
assert_eq!(ngl.allow.len(), 3);
assert_eq!(ngl.allow[2], "**/normalize-scope/**");
}
#[test]
fn legacy_layout_still_parses() {
let toml_str = r#"
global-allow = ["**/fixtures/**"]
["rust/dbg-macro"]
severity = "error"
"#;
let config: RulesConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.global_allow, vec!["**/fixtures/**"]);
let dbg = config.rules.get("rust/dbg-macro").unwrap();
assert_eq!(dbg.severity.as_deref(), Some("error"));
}
#[test]
fn nested_layout_does_not_collide_with_engine_keys() {
let toml_str = r#"
global-allow = ["**/fixtures/**"]
[rule."global-allow"]
severity = "error"
allow = ["legacy/**"]
"#;
let config: RulesConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.global_allow, vec!["**/fixtures/**"]);
let r = config.rules.get("global-allow").unwrap();
assert_eq!(r.severity.as_deref(), Some("error"));
assert_eq!(r.allow, vec!["legacy/**"]);
}
#[test]
fn nested_layout_wins_over_legacy_on_id_collision() {
let toml_str = r#"
[rule."rust/dbg-macro"]
severity = "warning"
["rust/dbg-macro"]
severity = "error"
"#;
let config: RulesConfig = toml::from_str(toml_str).unwrap();
let dbg = config.rules.get("rust/dbg-macro").unwrap();
assert_eq!(dbg.severity.as_deref(), Some("warning"));
}
#[test]
fn path_filter_empty_passes_everything() {
let f = PathFilter::default();
assert!(f.is_empty());
assert!(f.matches("anything/at/all.rs"));
}
#[test]
fn path_filter_only() {
let f = PathFilter::new(&["src/**/*.rs".into()], &[]);
assert!(f.matches("src/lib.rs"));
assert!(f.matches("src/deep/mod.rs"));
assert!(!f.matches("tests/integration.rs"));
}
#[test]
fn path_filter_exclude() {
let f = PathFilter::new(&[], &["**/tests/**".into()]);
assert!(f.matches("src/lib.rs"));
assert!(!f.matches("crates/foo/tests/bar.rs"));
}
#[test]
fn path_filter_only_and_exclude() {
let f = PathFilter::new(&["crates/**/*.rs".into()], &["**/tests/**".into()]);
assert!(f.matches("crates/foo/src/lib.rs"));
assert!(!f.matches("crates/foo/tests/it.rs")); assert!(!f.matches("src/main.rs")); }
#[test]
fn walk_config_defaults() {
let config = WalkConfig::default();
assert_eq!(config.ignore_files(), vec![".gitignore"]);
assert!(config.exclude().is_empty());
let root = Path::new("/tmp/root");
assert!(!config.is_excluded_path(root, Path::new(".git"), true));
assert!(!config.is_excluded_path(root, Path::new("src"), true));
}
#[test]
fn walk_config_custom() {
let config = WalkConfig {
ignore_files: Some(vec![".gitignore".into(), ".npmignore".into()]),
exclude: Some(vec![".git".into(), "node_modules".into()]),
};
assert_eq!(config.ignore_files(), vec![".gitignore", ".npmignore"]);
assert_eq!(config.exclude(), vec![".git", "node_modules"]);
let root = Path::new("/tmp/root");
assert!(config.is_excluded_path(root, Path::new("node_modules"), true));
assert!(!config.is_excluded_path(root, Path::new("src"), true));
}
#[test]
fn walk_config_empty_disables() {
let config = WalkConfig {
ignore_files: Some(vec![]),
exclude: Some(vec![]),
};
assert!(config.ignore_files().is_empty());
assert!(config.exclude().is_empty());
let root = Path::new("/tmp/root");
assert!(!config.is_excluded_path(root, Path::new(".git"), true));
}
#[test]
fn walk_config_excludes_basename_at_any_depth() {
let config = WalkConfig {
ignore_files: None,
exclude: Some(vec!["node_modules".into(), "worktrees".into()]),
};
let root = Path::new("/tmp/root");
assert!(config.is_excluded_path(root, Path::new("node_modules"), true));
assert!(config.is_excluded_path(root, Path::new("crates/foo/node_modules"), true));
assert!(config.is_excluded_path(root, Path::new(".claude/worktrees"), true));
}
#[test]
fn walk_config_excludes_anchored_glob() {
let config = WalkConfig {
ignore_files: None,
exclude: Some(vec!["**/target/".into(), "path/to/specific.rs".into()]),
};
let root = Path::new("/tmp/root");
assert!(config.is_excluded_path(root, Path::new("crates/foo/target"), true));
assert!(config.is_excluded_path(root, Path::new("target"), true));
assert!(config.is_excluded_path(root, Path::new("path/to/specific.rs"), false));
assert!(!config.is_excluded_path(root, Path::new("path/to/other.rs"), false));
}
#[test]
fn walk_config_deserialize() {
let toml_str = r#"
ignore_files = [".gitignore", ".dockerignore"]
exclude = [".git", "node_modules", ".cache"]
"#;
let config: WalkConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.ignore_files(), vec![".gitignore", ".dockerignore"]);
assert_eq!(config.exclude(), vec![".git", "node_modules", ".cache"]);
}
#[test]
fn walk_config_merge_option_semantics() {
use normalize_core::Merge;
let a = WalkConfig::default();
let b = WalkConfig::default();
let merged = a.merge(b);
assert_eq!(merged.ignore_files(), vec![".gitignore"]);
assert!(merged.exclude().is_empty());
let a = WalkConfig {
ignore_files: Some(vec![".npmignore".into()]),
exclude: Some(vec!["dist".into()]),
};
let b = WalkConfig::default();
let merged = a.merge(b);
assert_eq!(merged.ignore_files(), vec![".npmignore"]);
assert_eq!(merged.exclude(), vec!["dist"]);
let a = WalkConfig::default();
let b = WalkConfig {
ignore_files: Some(vec![".npmignore".into()]),
exclude: None,
};
let merged = a.merge(b);
assert_eq!(merged.ignore_files(), vec![".npmignore"]);
assert!(merged.exclude().is_empty()); }
fn parse_rules(s: &str) -> RulesConfig {
toml::from_str(s).unwrap()
}
#[test]
fn config_diff_no_change_is_empty() {
let cfg = parse_rules(
r#"
[rule."rust/dbg-macro"]
severity = "error"
"#,
);
let walk = WalkConfig::default();
let diff = ConfigDiff::compute(&cfg, &cfg, &walk, &walk);
assert!(diff.is_empty());
assert!(diff.is_filter_only());
assert!(!diff.requires_full_reprime());
}
#[test]
fn config_diff_severity_only_is_filter_only() {
let old = parse_rules(
r#"
[rule."rust/dbg-macro"]
severity = "error"
"#,
);
let new = parse_rules(
r#"
[rule."rust/dbg-macro"]
severity = "info"
"#,
);
let walk = WalkConfig::default();
let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
assert!(diff.severities_changed);
assert!(diff.is_filter_only());
assert!(diff.rules_to_rerun.is_empty());
}
#[test]
fn config_diff_allow_change_is_filter_only() {
let old = parse_rules(
r#"
global-allow = ["**/fixtures/**"]
"#,
);
let new = parse_rules(
r#"
global-allow = ["**/fixtures/**", "**/tests/**"]
"#,
);
let walk = WalkConfig::default();
let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
assert!(diff.allow_lists_changed);
assert!(diff.is_filter_only());
}
#[test]
fn config_diff_disable_is_filter_only() {
let old = parse_rules(
r#"
[rule."rust/dbg-macro"]
severity = "error"
"#,
);
let new = parse_rules(
r#"
[rule."rust/dbg-macro"]
severity = "error"
enabled = false
"#,
);
let walk = WalkConfig::default();
let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
assert!(diff.rules_disabled.contains("rust/dbg-macro"));
assert!(diff.rules_to_rerun.is_empty());
assert!(diff.is_filter_only());
}
#[test]
fn config_diff_enable_requires_rerun() {
let old = parse_rules(
r#"
[rule."rust/dbg-macro"]
enabled = false
"#,
);
let new = parse_rules(
r#"
[rule."rust/dbg-macro"]
enabled = true
"#,
);
let walk = WalkConfig::default();
let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
assert!(diff.rules_to_rerun.contains("rust/dbg-macro"));
assert!(!diff.is_filter_only());
assert!(!diff.requires_full_reprime());
}
#[test]
fn config_diff_threshold_change_requires_rerun() {
let old = parse_rules(
r#"
[rule."long-function"]
threshold = 100
"#,
);
let new = parse_rules(
r#"
[rule."long-function"]
threshold = 50
"#,
);
let walk = WalkConfig::default();
let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
assert!(diff.rules_to_rerun.contains("long-function"));
}
#[test]
fn config_diff_walk_exclude_change_requires_full_reprime() {
let cfg = RulesConfig::default();
let old_walk = WalkConfig::default();
let new_walk = WalkConfig {
ignore_files: None,
exclude: Some(vec![".git".into(), "node_modules".into()]),
};
let diff = ConfigDiff::compute(&cfg, &cfg, &old_walk, &new_walk);
assert!(diff.walk_exclude_changed);
assert!(diff.requires_full_reprime());
assert!(!diff.is_filter_only());
}
}