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 fn id(&self) -> &str {
49 &self.id
50 }
51 fn level(&self) -> Level {
52 self.level
53 }
54 fn policy_url(&self) -> Option<&str> {
55 self.policy_url.as_deref()
56 }
57
58 fn requires_full_index(&self) -> bool {
59 true
64 }
65
66 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
67 evaluate_for_each(
68 &self.id,
69 self.level,
70 &self.select_scope,
71 self.when_iter.as_ref(),
72 &self.require,
73 ctx,
74 IterateMode::Both,
75 )
76 }
77}
78
79pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
80 alint_core::reject_scope_filter_on_cross_file(spec, "every_matching_has")?;
81 let opts: Options = spec
82 .deserialize_options()
83 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
84 if opts.require.is_empty() {
85 return Err(Error::rule_config(
86 &spec.id,
87 "every_matching_has requires at least one nested rule under `require:`",
88 ));
89 }
90 let select_scope = Scope::from_patterns(&[opts.select])?;
91 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
92 let require = compile_nested_require(&spec.id, opts.require)?;
93 Ok(Box::new(EveryMatchingHasRule {
94 id: spec.id.clone(),
95 level: spec.level,
96 policy_url: spec.policy_url.clone(),
97 select_scope,
98 when_iter,
99 require,
100 }))
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use alint_core::{FileEntry, FileIndex, RuleRegistry};
107 use std::path::Path;
108
109 fn index(entries: &[(&str, bool)]) -> FileIndex {
110 FileIndex::from_entries(
111 entries
112 .iter()
113 .map(|(p, is_dir)| FileEntry {
114 path: std::path::Path::new(p).into(),
115 is_dir: *is_dir,
116 size: 1,
117 })
118 .collect(),
119 )
120 }
121
122 fn registry() -> RuleRegistry {
123 crate::builtin_registry()
124 }
125
126 #[test]
127 fn iterates_both_files_and_dirs() {
128 let require: Vec<NestedRuleSpec> =
132 vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
133 let require = compile_nested_require("t", require).unwrap();
134 let r = EveryMatchingHasRule {
135 id: "t".into(),
136 level: Level::Error,
137 policy_url: None,
138 select_scope: Scope::from_patterns(&["packages/*".to_string()]).unwrap(),
139 when_iter: None,
140 require,
141 };
142 let idx = index(&[
143 ("packages", true),
144 ("packages/a", true),
145 ("packages/x.md", false),
146 ]);
147 let reg = registry();
148 let ctx = Context {
149 root: Path::new("/"),
150 index: &idx,
151 registry: Some(®),
152 facts: None,
153 vars: None,
154 git_tracked: None,
155 git_blame: None,
156 };
157 let v = r.evaluate(&ctx).unwrap();
158 assert_eq!(v.len(), 1);
163 assert_eq!(v[0].path.as_deref(), Some(Path::new("packages/a")));
164 }
165
166 #[test]
167 fn build_rejects_scope_filter_on_cross_file_rule() {
168 let yaml = r#"
174id: t
175kind: every_matching_has
176select: "packages/*"
177require:
178 - kind: file_exists
179 paths: "{path}/README.md"
180level: error
181scope_filter:
182 has_ancestor: Cargo.toml
183"#;
184 let spec = crate::test_support::spec_yaml(yaml);
185 let err = build(&spec).unwrap_err().to_string();
186 assert!(
187 err.contains("scope_filter is supported on per-file rules only"),
188 "expected per-file-only message, got: {err}",
189 );
190 assert!(
191 err.contains("every_matching_has"),
192 "expected message to name the cross-file kind, got: {err}",
193 );
194 }
195}