alint_rules/
every_matching_has.rs1use alint_core::when::WhenExpr;
18use alint_core::{
19 CompiledNestedSpec, Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope,
20 Violation,
21};
22use serde::Deserialize;
23
24use crate::for_each_dir::{
25 IterateMode, compile_nested_require, evaluate_for_each, parse_when_iter,
26};
27
28#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30struct Options {
31 select: String,
32 #[serde(default)]
33 when_iter: Option<String>,
34 require: Vec<NestedRuleSpec>,
35}
36
37#[derive(Debug)]
38pub struct EveryMatchingHasRule {
39 id: String,
40 level: Level,
41 policy_url: Option<String>,
42 select_scope: Scope,
43 when_iter: Option<WhenExpr>,
44 require: Vec<CompiledNestedSpec>,
45}
46
47impl Rule for EveryMatchingHasRule {
48 alint_core::rule_common_impl!();
49
50 fn requires_full_index(&self) -> bool {
51 true
56 }
57
58 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
59 evaluate_for_each(
60 &self.id,
61 self.level,
62 &self.select_scope,
63 self.when_iter.as_ref(),
64 &self.require,
65 ctx,
66 IterateMode::Both,
67 )
68 }
69}
70
71pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
72 alint_core::reject_scope_filter_on_cross_file(spec, "every_matching_has")?;
73 let opts: Options = spec
74 .deserialize_options()
75 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
76 if opts.require.is_empty() {
77 return Err(Error::rule_config(
78 &spec.id,
79 "every_matching_has requires at least one nested rule under `require:`",
80 ));
81 }
82 let select_scope = Scope::from_patterns(&[opts.select])?;
83 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
84 let require = compile_nested_require(&spec.id, opts.require)?;
85 Ok(Box::new(EveryMatchingHasRule {
86 id: spec.id.clone(),
87 level: spec.level,
88 policy_url: spec.policy_url.clone(),
89 select_scope,
90 when_iter,
91 require,
92 }))
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use alint_core::{FileEntry, FileIndex, RuleRegistry};
99 use std::path::Path;
100
101 fn index(entries: &[(&str, bool)]) -> FileIndex {
102 FileIndex::from_entries(
103 entries
104 .iter()
105 .map(|(p, is_dir)| FileEntry {
106 path: std::path::Path::new(p).into(),
107 is_dir: *is_dir,
108 size: 1,
109 })
110 .collect(),
111 )
112 }
113
114 fn registry() -> RuleRegistry {
115 crate::builtin_registry()
116 }
117
118 #[test]
119 fn iterates_both_files_and_dirs() {
120 let require: Vec<NestedRuleSpec> =
124 vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
125 let require = compile_nested_require("t", require).unwrap();
126 let r = EveryMatchingHasRule {
127 id: "t".into(),
128 level: Level::Error,
129 policy_url: None,
130 select_scope: Scope::from_patterns(&["packages/*".to_string()]).unwrap(),
131 when_iter: None,
132 require,
133 };
134 let idx = index(&[
135 ("packages", true),
136 ("packages/a", true),
137 ("packages/x.md", false),
138 ]);
139 let reg = registry();
140 let ctx = Context {
141 root: Path::new("/"),
142 index: &idx,
143 registry: Some(®),
144 facts: None,
145 vars: None,
146 git_tracked: None,
147 git_blame: None,
148 };
149 let v = r.evaluate(&ctx).unwrap();
150 assert_eq!(v.len(), 1);
155 assert_eq!(v[0].path.as_deref(), Some(Path::new("packages/a")));
156 }
157
158 #[test]
159 fn build_rejects_scope_filter_on_cross_file_rule() {
160 let yaml = r#"
166id: t
167kind: every_matching_has
168select: "packages/*"
169require:
170 - kind: file_exists
171 paths: "{path}/README.md"
172level: error
173scope_filter:
174 has_ancestor: Cargo.toml
175"#;
176 let spec = crate::test_support::spec_yaml(yaml);
177 let err = build(&spec).unwrap_err().to_string();
178 assert!(
179 err.contains("scope_filter is supported on per-file rules only"),
180 "expected per-file-only message, got: {err}",
181 );
182 assert!(
183 err.contains("every_matching_has"),
184 "expected message to name the cross-file kind, got: {err}",
185 );
186 }
187}