use alint_core::when::WhenExpr;
use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
use serde::Deserialize;
use crate::for_each_dir::{IterateMode, evaluate_for_each, parse_when_iter};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
select: String,
#[serde(default)]
when_iter: Option<String>,
require: Vec<NestedRuleSpec>,
}
#[derive(Debug)]
pub struct EveryMatchingHasRule {
id: String,
level: Level,
policy_url: Option<String>,
select_scope: Scope,
when_iter: Option<WhenExpr>,
require: Vec<NestedRuleSpec>,
}
impl Rule for EveryMatchingHasRule {
fn id(&self) -> &str {
&self.id
}
fn level(&self) -> Level {
self.level
}
fn policy_url(&self) -> Option<&str> {
self.policy_url.as_deref()
}
fn requires_full_index(&self) -> bool {
true
}
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
evaluate_for_each(
&self.id,
self.level,
&self.select_scope,
self.when_iter.as_ref(),
&self.require,
ctx,
IterateMode::Both,
)
}
}
pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
alint_core::reject_scope_filter_on_cross_file(spec, "every_matching_has")?;
let opts: Options = spec
.deserialize_options()
.map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
if opts.require.is_empty() {
return Err(Error::rule_config(
&spec.id,
"every_matching_has requires at least one nested rule under `require:`",
));
}
let select_scope = Scope::from_patterns(&[opts.select])?;
let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
Ok(Box::new(EveryMatchingHasRule {
id: spec.id.clone(),
level: spec.level,
policy_url: spec.policy_url.clone(),
select_scope,
when_iter,
require: opts.require,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use alint_core::{FileEntry, FileIndex, RuleRegistry};
use std::path::Path;
fn index(entries: &[(&str, bool)]) -> FileIndex {
FileIndex::from_entries(
entries
.iter()
.map(|(p, is_dir)| FileEntry {
path: std::path::Path::new(p).into(),
is_dir: *is_dir,
size: 1,
})
.collect(),
)
}
fn registry() -> RuleRegistry {
crate::builtin_registry()
}
#[test]
fn iterates_both_files_and_dirs() {
let require: Vec<NestedRuleSpec> =
vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
let r = EveryMatchingHasRule {
id: "t".into(),
level: Level::Error,
policy_url: None,
select_scope: Scope::from_patterns(&["packages/*".to_string()]).unwrap(),
when_iter: None,
require,
};
let idx = index(&[
("packages", true),
("packages/a", true),
("packages/x.md", false),
]);
let reg = registry();
let ctx = Context {
root: Path::new("/"),
index: &idx,
registry: Some(®),
facts: None,
vars: None,
git_tracked: None,
git_blame: None,
};
let v = r.evaluate(&ctx).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].path.as_deref(), Some(Path::new("packages/a")));
}
#[test]
fn build_rejects_scope_filter_on_cross_file_rule() {
let yaml = r#"
id: t
kind: every_matching_has
select: "packages/*"
require:
- kind: file_exists
paths: "{path}/README.md"
level: error
scope_filter:
has_ancestor: Cargo.toml
"#;
let spec = crate::test_support::spec_yaml(yaml);
let err = build(&spec).unwrap_err().to_string();
assert!(
err.contains("scope_filter is supported on per-file rules only"),
"expected per-file-only message, got: {err}",
);
assert!(
err.contains("every_matching_has"),
"expected message to name the cross-file kind, got: {err}",
);
}
}