1use alint_core::template::PathTokens;
33use alint_core::when::{IterEnv, WhenExpr};
34use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
35use serde::Deserialize;
36
37#[derive(Debug, Deserialize)]
38#[serde(deny_unknown_fields)]
39struct Options {
40 select: String,
41 #[serde(default)]
46 when_iter: Option<String>,
47 require: Vec<NestedRuleSpec>,
48}
49
50#[derive(Debug)]
51pub struct ForEachDirRule {
52 id: String,
53 level: Level,
54 policy_url: Option<String>,
55 select_scope: Scope,
56 when_iter: Option<WhenExpr>,
57 require: Vec<NestedRuleSpec>,
58}
59
60impl Rule for ForEachDirRule {
61 fn id(&self) -> &str {
62 &self.id
63 }
64 fn level(&self) -> Level {
65 self.level
66 }
67 fn policy_url(&self) -> Option<&str> {
68 self.policy_url.as_deref()
69 }
70
71 fn requires_full_index(&self) -> bool {
72 true
78 }
79
80 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
81 evaluate_for_each(
82 &self.id,
83 self.level,
84 &self.select_scope,
85 self.when_iter.as_ref(),
86 &self.require,
87 ctx,
88 IterateMode::Dirs,
89 )
90 }
91}
92
93pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
94 let opts: Options = spec
95 .deserialize_options()
96 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
97 if opts.require.is_empty() {
98 return Err(Error::rule_config(
99 &spec.id,
100 "for_each_dir requires at least one nested rule under `require:`",
101 ));
102 }
103 let select_scope = Scope::from_patterns(&[opts.select])?;
104 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
105 Ok(Box::new(ForEachDirRule {
106 id: spec.id.clone(),
107 level: spec.level,
108 policy_url: spec.policy_url.clone(),
109 select_scope,
110 when_iter,
111 require: opts.require,
112 }))
113}
114
115pub(crate) fn parse_when_iter(spec: &RuleSpec, src: Option<&str>) -> Result<Option<WhenExpr>> {
120 let Some(src) = src else { return Ok(None) };
121 alint_core::when::parse(src)
122 .map(Some)
123 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `when_iter:`: {e}")))
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(crate) enum IterateMode {
129 Dirs,
130 Files,
131 Both,
133}
134
135pub(crate) fn evaluate_for_each(
141 parent_id: &str,
142 level: Level,
143 select_scope: &Scope,
144 when_iter: Option<&WhenExpr>,
145 require: &[NestedRuleSpec],
146 ctx: &Context<'_>,
147 mode: IterateMode,
148) -> Result<Vec<Violation>> {
149 let Some(registry) = ctx.registry else {
150 return Err(Error::Other(format!(
151 "rule {parent_id}: nested-rule evaluation needs a RuleRegistry in the Context \
152 (likely an Engine constructed without one)",
153 )));
154 };
155
156 let entries: Box<dyn Iterator<Item = _>> = match mode {
157 IterateMode::Dirs => Box::new(ctx.index.dirs()),
158 IterateMode::Files => Box::new(ctx.index.files()),
159 IterateMode::Both => Box::new(ctx.index.dirs().chain(ctx.index.files())),
160 };
161
162 let mut violations = Vec::new();
163 for entry in entries {
164 if !select_scope.matches(&entry.path) {
165 continue;
166 }
167
168 let iter_env = IterEnv {
173 path: &entry.path,
174 is_dir: entry.is_dir,
175 index: ctx.index,
176 };
177 if let Some(expr) = when_iter {
178 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
179 let env = alint_core::WhenEnv {
180 facts,
181 vars,
182 iter: Some(iter_env),
183 };
184 match expr.evaluate(&env) {
185 Ok(true) => {}
186 Ok(false) => continue,
187 Err(e) => {
188 violations.push(
189 Violation::new(format!("{parent_id}: when_iter error: {e}"))
190 .with_path(&entry.path),
191 );
192 continue;
193 }
194 }
195 }
196 }
197
198 let tokens = PathTokens::from_path(&entry.path);
199 for (i, nested) in require.iter().enumerate() {
200 let nested_spec = nested.instantiate(parent_id, i, level, &tokens);
201 if let Some(when_src) = &nested_spec.when {
206 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
207 let expr = alint_core::when::parse(when_src).map_err(|e| {
208 Error::rule_config(
209 parent_id,
210 format!("nested rule #{i}: invalid when: {e}"),
211 )
212 })?;
213 let env = alint_core::WhenEnv {
214 facts,
215 vars,
216 iter: Some(iter_env),
217 };
218 match expr.evaluate(&env) {
219 Ok(true) => {}
220 Ok(false) => continue,
221 Err(e) => {
222 violations.push(
223 Violation::new(format!(
224 "{parent_id}: nested rule #{i} when error: {e}"
225 ))
226 .with_path(&entry.path),
227 );
228 continue;
229 }
230 }
231 }
232 }
233 let nested_rule = match registry.build(&nested_spec) {
234 Ok(r) => r,
235 Err(e) => {
236 violations.push(
237 Violation::new(format!(
238 "{parent_id}: failed to build nested rule #{i} for {}: {e}",
239 entry.path.display()
240 ))
241 .with_path(&entry.path),
242 );
243 continue;
244 }
245 };
246 let nested_violations = nested_rule.evaluate(ctx)?;
247 for mut v in nested_violations {
248 if v.path.is_none() {
249 v.path = Some(entry.path.clone());
250 }
251 violations.push(v);
252 }
253 }
254 }
255 Ok(violations)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use alint_core::{FileEntry, FileIndex, RuleRegistry};
262 use std::path::{Path, PathBuf};
263
264 fn index(entries: &[(&str, bool)]) -> FileIndex {
265 FileIndex {
266 entries: entries
267 .iter()
268 .map(|(p, is_dir)| FileEntry {
269 path: PathBuf::from(p),
270 is_dir: *is_dir,
271 size: 1,
272 })
273 .collect(),
274 }
275 }
276
277 fn registry() -> RuleRegistry {
278 crate::builtin_registry()
279 }
280
281 fn eval_with(rule: &ForEachDirRule, files: &[(&str, bool)]) -> Vec<Violation> {
282 let idx = index(files);
283 let reg = registry();
284 let ctx = Context {
285 root: Path::new("/"),
286 index: &idx,
287 registry: Some(®),
288 facts: None,
289 vars: None,
290 git_tracked: None,
291 };
292 rule.evaluate(&ctx).unwrap()
293 }
294
295 fn rule(select: &str, require: Vec<NestedRuleSpec>) -> ForEachDirRule {
296 ForEachDirRule {
297 id: "t".into(),
298 level: Level::Error,
299 policy_url: None,
300 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
301 when_iter: None,
302 require,
303 }
304 }
305
306 fn require_file_exists(path: &str) -> NestedRuleSpec {
307 let yaml = format!("kind: file_exists\npaths: \"{path}\"\n");
309 serde_yaml_ng::from_str(&yaml).unwrap()
310 }
311
312 #[test]
313 fn passes_when_every_dir_has_required_file() {
314 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
315 let v = eval_with(
316 &r,
317 &[
318 ("src", true),
319 ("src/foo", true),
320 ("src/foo/mod.rs", false),
321 ("src/bar", true),
322 ("src/bar/mod.rs", false),
323 ],
324 );
325 assert!(v.is_empty(), "unexpected: {v:?}");
326 }
327
328 #[test]
329 fn violates_when_a_dir_missing_required_file() {
330 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
331 let v = eval_with(
332 &r,
333 &[
334 ("src", true),
335 ("src/foo", true),
336 ("src/foo/mod.rs", false),
337 ("src/bar", true), ],
339 );
340 assert_eq!(v.len(), 1);
341 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/bar")));
342 }
343
344 #[test]
345 fn no_matched_dirs_means_no_violations() {
346 let r = rule("components/*", vec![require_file_exists("{dir}/index.tsx")]);
347 let v = eval_with(&r, &[("src", true), ("src/foo", true)]);
348 assert!(v.is_empty());
349 }
350
351 #[test]
352 fn every_require_rule_evaluated_per_dir() {
353 let r = rule(
354 "src/*",
355 vec![
356 require_file_exists("{path}/mod.rs"),
357 require_file_exists("{path}/README.md"),
358 ],
359 );
360 let v = eval_with(
361 &r,
362 &[
363 ("src", true),
364 ("src/foo", true),
365 ("src/foo/mod.rs", false), ],
367 );
368 assert_eq!(v.len(), 1);
369 assert!(
370 v[0].message.contains("README"),
371 "expected README in message; got {:?}",
372 v[0].message
373 );
374 }
375}