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