use std::collections::HashMap;
use std::path::PathBuf;
use serde::Deserialize;
use crate::facts::FactSpec;
use crate::level::Level;
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub version: u32,
#[serde(default)]
pub extends: Vec<ExtendsEntry>,
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default = "default_respect_gitignore")]
pub respect_gitignore: bool,
#[serde(default)]
pub vars: HashMap<String, String>,
#[serde(default)]
pub facts: Vec<FactSpec>,
#[serde(default)]
pub rules: Vec<RuleSpec>,
#[serde(default = "default_fix_size_limit")]
pub fix_size_limit: Option<u64>,
#[serde(default)]
pub nested_configs: bool,
}
#[allow(clippy::unnecessary_wraps)]
fn default_fix_size_limit() -> Option<u64> {
Some(1 << 20)
}
fn default_respect_gitignore() -> bool {
true
}
impl Config {
pub const CURRENT_VERSION: u32 = 1;
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ExtendsEntry {
Url(String),
Filtered {
url: String,
#[serde(default)]
only: Option<Vec<String>>,
#[serde(default)]
except: Option<Vec<String>>,
},
}
impl ExtendsEntry {
pub fn url(&self) -> &str {
match self {
Self::Url(s) | Self::Filtered { url: s, .. } => s,
}
}
pub fn only(&self) -> Option<&[String]> {
match self {
Self::Filtered { only: Some(v), .. } => Some(v),
_ => None,
}
}
pub fn except(&self) -> Option<&[String]> {
match self {
Self::Filtered {
except: Some(v), ..
} => Some(v),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PathsSpec {
Single(String),
Many(Vec<String>),
IncludeExclude {
#[serde(default, deserialize_with = "string_or_vec")]
include: Vec<String>,
#[serde(default, deserialize_with = "string_or_vec")]
exclude: Vec<String>,
},
}
fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(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),
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RuleSpec {
pub id: String,
pub kind: String,
pub level: Level,
#[serde(default)]
pub paths: Option<PathsSpec>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub policy_url: Option<String>,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub fix: Option<FixSpec>,
#[serde(default)]
pub git_tracked_only: bool,
#[serde(flatten)]
pub extra: serde_yaml_ng::Mapping,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum FixSpec {
FileCreate {
file_create: FileCreateFixSpec,
},
FileRemove {
file_remove: FileRemoveFixSpec,
},
FilePrepend {
file_prepend: FilePrependFixSpec,
},
FileAppend {
file_append: FileAppendFixSpec,
},
FileRename {
file_rename: FileRenameFixSpec,
},
FileTrimTrailingWhitespace {
file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
},
FileAppendFinalNewline {
file_append_final_newline: FileAppendFinalNewlineFixSpec,
},
FileNormalizeLineEndings {
file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
},
FileStripBidi {
file_strip_bidi: FileStripBidiFixSpec,
},
FileStripZeroWidth {
file_strip_zero_width: FileStripZeroWidthFixSpec,
},
FileStripBom {
file_strip_bom: FileStripBomFixSpec,
},
FileCollapseBlankLines {
file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
},
}
impl FixSpec {
pub fn op_name(&self) -> &'static str {
match self {
Self::FileCreate { .. } => "file_create",
Self::FileRemove { .. } => "file_remove",
Self::FilePrepend { .. } => "file_prepend",
Self::FileAppend { .. } => "file_append",
Self::FileRename { .. } => "file_rename",
Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
Self::FileStripBidi { .. } => "file_strip_bidi",
Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
Self::FileStripBom { .. } => "file_strip_bom",
Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileCreateFixSpec {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub content_from: Option<PathBuf>,
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default = "default_create_parents")]
pub create_parents: bool,
}
fn default_create_parents() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileRemoveFixSpec {}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FilePrependFixSpec {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub content_from: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileAppendFixSpec {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub content_from: Option<PathBuf>,
}
pub fn resolve_content_source(
rule_id: &str,
op_name: &str,
inline: &Option<String>,
from: &Option<PathBuf>,
) -> crate::error::Result<ContentSourceSpec> {
match (inline, from) {
(Some(_), Some(_)) => Err(crate::error::Error::rule_config(
rule_id,
format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
)),
(None, None) => Err(crate::error::Error::rule_config(
rule_id,
format!("fix.{op_name}: one of `content` or `content_from` is required"),
)),
(Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
(None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
}
}
#[derive(Debug, Clone)]
pub enum ContentSourceSpec {
Inline(String),
File(PathBuf),
}
impl From<String> for ContentSourceSpec {
fn from(s: String) -> Self {
Self::Inline(s)
}
}
impl From<&str> for ContentSourceSpec {
fn from(s: &str) -> Self {
Self::Inline(s.to_string())
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileRenameFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileTrimTrailingWhitespaceFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileAppendFinalNewlineFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileNormalizeLineEndingsFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileStripBidiFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileStripZeroWidthFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileStripBomFixSpec {}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileCollapseBlankLinesFixSpec {}
impl RuleSpec {
pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
where
T: serde::de::DeserializeOwned,
{
Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
self.extra.clone(),
))?)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct NestedRuleSpec {
pub kind: String,
#[serde(default)]
pub paths: Option<PathsSpec>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub policy_url: Option<String>,
#[serde(default)]
pub when: Option<String>,
#[serde(flatten)]
pub extra: serde_yaml_ng::Mapping,
}
impl NestedRuleSpec {
pub fn instantiate(
&self,
parent_id: &str,
idx: usize,
level: Level,
tokens: &crate::template::PathTokens,
) -> RuleSpec {
RuleSpec {
id: format!("{parent_id}.require[{idx}]"),
kind: self.kind.clone(),
level,
paths: self
.paths
.as_ref()
.map(|p| crate::template::render_paths_spec(p, tokens)),
message: self
.message
.as_deref()
.map(|m| crate::template::render_path(m, tokens)),
policy_url: self.policy_url.clone(),
when: self.when.clone(),
fix: None,
git_tracked_only: false,
extra: crate::template::render_mapping(self.extra.clone(), tokens),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::template::PathTokens;
use std::path::Path;
#[test]
fn config_default_respects_gitignore_and_caps_fix_size() {
let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
assert_eq!(cfg.version, 1);
assert!(cfg.respect_gitignore);
assert_eq!(cfg.fix_size_limit, Some(1 << 20));
assert!(!cfg.nested_configs);
assert!(cfg.extends.is_empty());
assert!(cfg.rules.is_empty());
}
#[test]
fn config_rejects_unknown_top_level_field() {
let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
assert!(err.is_err(), "deny_unknown_fields should reject typos");
}
#[test]
fn config_explicit_null_disables_fix_size_limit() {
let cfg: Config =
serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
assert_eq!(cfg.fix_size_limit, None);
}
#[test]
fn extends_entry_url_form_has_no_filters() {
let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
assert!(e.only().is_none());
assert!(e.except().is_none());
}
#[test]
fn extends_entry_filtered_form_exposes_only_and_except() {
let e = ExtendsEntry::Filtered {
url: "alint://bundled/rust@v1".into(),
only: Some(vec!["rust-edition".into()]),
except: None,
};
assert_eq!(e.url(), "alint://bundled/rust@v1");
assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
assert!(e.except().is_none());
}
#[test]
fn extends_entry_filtered_form_supports_except_only() {
let e = ExtendsEntry::Filtered {
url: "./team.yml".into(),
only: None,
except: Some(vec!["legacy-rule".into()]),
};
assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
assert!(e.only().is_none());
}
#[test]
fn paths_spec_accepts_three_shapes() {
let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));
let many: PathsSpec =
serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));
let inc_exc: PathsSpec =
serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
match inc_exc {
PathsSpec::IncludeExclude { include, exclude } => {
assert_eq!(include, vec!["src/**"]);
assert_eq!(exclude, vec!["src/vendor/**"]);
}
_ => panic!("expected include/exclude shape"),
}
}
#[test]
fn paths_spec_include_accepts_string_or_vec() {
let from_string: PathsSpec =
serde_yaml_ng::from_str("include: a\nexclude:\n - b\n - c\n").unwrap();
let PathsSpec::IncludeExclude { include, exclude } = from_string else {
panic!("expected include/exclude shape");
};
assert_eq!(include, vec!["a"]);
assert_eq!(exclude, vec!["b", "c"]);
}
#[test]
fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
#[derive(Deserialize, Debug)]
struct PatternOnly {
pattern: String,
}
let spec: RuleSpec = serde_yaml_ng::from_str(
"id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
)
.unwrap();
let opts: PatternOnly = spec.deserialize_options().unwrap();
assert_eq!(opts.pattern, "TODO");
}
#[test]
fn fix_spec_op_name_covers_every_variant() {
let cases = [
("file_create:\n content: x\n", "file_create"),
("file_remove: {}", "file_remove"),
("file_prepend:\n content: x\n", "file_prepend"),
("file_append:\n content: x\n", "file_append"),
("file_rename: {}", "file_rename"),
(
"file_trim_trailing_whitespace: {}",
"file_trim_trailing_whitespace",
),
("file_append_final_newline: {}", "file_append_final_newline"),
(
"file_normalize_line_endings: {}",
"file_normalize_line_endings",
),
("file_strip_bidi: {}", "file_strip_bidi"),
("file_strip_zero_width: {}", "file_strip_zero_width"),
("file_strip_bom: {}", "file_strip_bom"),
("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
];
for (yaml, expected) in cases {
let spec: FixSpec =
serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
assert_eq!(spec.op_name(), expected);
}
}
#[test]
fn resolve_content_source_inline_only() {
let s = Some("hello".to_string());
let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
}
#[test]
fn resolve_content_source_file_only() {
let p = Some(PathBuf::from("LICENSE"));
let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
}
#[test]
fn resolve_content_source_rejects_both_set() {
let err = resolve_content_source(
"r",
"file_prepend",
&Some("x".into()),
&Some(PathBuf::from("y")),
)
.unwrap_err();
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn resolve_content_source_rejects_neither_set() {
let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
assert!(err.to_string().contains("required"));
}
#[test]
fn content_source_spec_from_string_variants() {
let from_owned: ContentSourceSpec = String::from("hi").into();
assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
let from_str: ContentSourceSpec = "hi".into();
assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
}
#[test]
fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
let nested: NestedRuleSpec = serde_yaml_ng::from_str(
"kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
)
.unwrap();
let tokens = PathTokens::from_path(Path::new("packages/foo"));
let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);
assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
assert_eq!(spec.kind, "file_exists");
assert_eq!(spec.level, Level::Error);
match spec.paths {
Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
other => panic!("unexpected paths shape: {other:?}"),
}
assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
assert!(!spec.git_tracked_only);
}
}