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 alint_core::reject_scope_filter_on_cross_file(spec, "for_each_dir")?;
95 let opts: Options = spec
96 .deserialize_options()
97 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
98 if opts.require.is_empty() {
99 return Err(Error::rule_config(
100 &spec.id,
101 "for_each_dir requires at least one nested rule under `require:`",
102 ));
103 }
104 let select_scope = Scope::from_patterns(&[opts.select])?;
105 let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
106 Ok(Box::new(ForEachDirRule {
107 id: spec.id.clone(),
108 level: spec.level,
109 policy_url: spec.policy_url.clone(),
110 select_scope,
111 when_iter,
112 require: opts.require,
113 }))
114}
115
116pub(crate) fn parse_when_iter(spec: &RuleSpec, src: Option<&str>) -> Result<Option<WhenExpr>> {
121 let Some(src) = src else { return Ok(None) };
122 alint_core::when::parse(src)
123 .map(Some)
124 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `when_iter:`: {e}")))
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub(crate) enum IterateMode {
130 Dirs,
131 Files,
132 Both,
134}
135
136#[allow(clippy::too_many_lines)]
149pub(crate) fn evaluate_for_each(
150 parent_id: &str,
151 level: Level,
152 select_scope: &Scope,
153 when_iter: Option<&WhenExpr>,
154 require: &[NestedRuleSpec],
155 ctx: &Context<'_>,
156 mode: IterateMode,
157) -> Result<Vec<Violation>> {
158 let Some(registry) = ctx.registry else {
159 return Err(Error::Other(format!(
160 "rule {parent_id}: nested-rule evaluation needs a RuleRegistry in the Context \
161 (likely an Engine constructed without one)",
162 )));
163 };
164
165 let entries: Box<dyn Iterator<Item = _>> = match mode {
166 IterateMode::Dirs => Box::new(ctx.index.dirs()),
167 IterateMode::Files => Box::new(ctx.index.files()),
168 IterateMode::Both => Box::new(ctx.index.dirs().chain(ctx.index.files())),
169 };
170
171 let mut violations = Vec::new();
172 for entry in entries {
173 if !select_scope.matches(&entry.path) {
174 continue;
175 }
176
177 let iter_env = IterEnv {
182 path: &entry.path,
183 is_dir: entry.is_dir,
184 index: ctx.index,
185 };
186 if let Some(expr) = when_iter {
187 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
188 let env = alint_core::WhenEnv {
189 facts,
190 vars,
191 iter: Some(iter_env),
192 };
193 match expr.evaluate(&env) {
194 Ok(true) => {}
195 Ok(false) => continue,
196 Err(e) => {
197 violations.push(
198 Violation::new(format!("{parent_id}: when_iter error: {e}"))
199 .with_path(entry.path.clone()),
200 );
201 continue;
202 }
203 }
204 }
205 }
206
207 let tokens = PathTokens::from_path(&entry.path);
208 for (i, nested) in require.iter().enumerate() {
209 let nested_spec = nested.instantiate(parent_id, i, level, &tokens);
210 if let Some(when_src) = &nested_spec.when {
215 if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
216 let expr = alint_core::when::parse(when_src).map_err(|e| {
217 Error::rule_config(
218 parent_id,
219 format!("nested rule #{i}: invalid when: {e}"),
220 )
221 })?;
222 let env = alint_core::WhenEnv {
223 facts,
224 vars,
225 iter: Some(iter_env),
226 };
227 match expr.evaluate(&env) {
228 Ok(true) => {}
229 Ok(false) => continue,
230 Err(e) => {
231 violations.push(
232 Violation::new(format!(
233 "{parent_id}: nested rule #{i} when error: {e}"
234 ))
235 .with_path(entry.path.clone()),
236 );
237 continue;
238 }
239 }
240 }
241 }
242 let nested_rule = match registry.build(&nested_spec) {
243 Ok(r) => r,
244 Err(e) => {
245 violations.push(
246 Violation::new(format!(
247 "{parent_id}: failed to build nested rule #{i} for {}: {e}",
248 entry.path.display()
249 ))
250 .with_path(entry.path.clone()),
251 );
252 continue;
253 }
254 };
255 if let Some(literal) = nested_spec_single_literal(&nested_spec)
274 && let Some(pf) = nested_rule.as_per_file()
275 && pf.path_scope().matches(&literal)
276 {
277 let nested_violations = evaluate_one_per_file_rule(parent_id, i, &literal, pf, ctx);
278 for mut v in nested_violations {
279 if v.path.is_none() {
280 v.path = Some(entry.path.clone());
281 }
282 violations.push(v);
283 }
284 continue;
285 }
286 let nested_violations = nested_rule.evaluate(ctx)?;
287 for mut v in nested_violations {
288 if v.path.is_none() {
289 v.path = Some(entry.path.clone());
290 }
291 violations.push(v);
292 }
293 }
294 }
295 Ok(violations)
296}
297
298fn nested_spec_single_literal(spec: &alint_core::RuleSpec) -> Option<std::path::PathBuf> {
313 use alint_core::PathsSpec;
314 let paths = spec.paths.as_ref()?;
315 let single: &str = match paths {
316 PathsSpec::Single(s) => s,
317 PathsSpec::Many(v) if v.len() == 1 => &v[0],
318 _ => return None,
319 };
320 if single.is_empty() || single.starts_with('!') {
321 return None;
322 }
323 if single
324 .chars()
325 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
326 {
327 return None;
328 }
329 Some(std::path::PathBuf::from(single))
330}
331
332fn evaluate_one_per_file_rule(
337 parent_id: &str,
338 nested_i: usize,
339 literal: &std::path::Path,
340 pf: &dyn alint_core::PerFileRule,
341 ctx: &Context<'_>,
342) -> Vec<Violation> {
343 if !ctx.index.contains_file(literal) {
344 return Vec::new();
348 }
349 let abs = ctx.root.join(literal);
350 let Ok(bytes) = std::fs::read(&abs) else {
351 return Vec::new();
354 };
355 match pf.evaluate_file(ctx, literal, &bytes) {
356 Ok(vs) => vs,
357 Err(e) => vec![Violation::new(format!(
358 "{parent_id}: nested rule #{nested_i} error on {}: {e}",
359 literal.display()
360 ))],
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use alint_core::{FileEntry, FileIndex, RuleRegistry};
368 use std::path::Path;
369
370 fn index(entries: &[(&str, bool)]) -> FileIndex {
371 FileIndex::from_entries(
372 entries
373 .iter()
374 .map(|(p, is_dir)| FileEntry {
375 path: std::path::Path::new(p).into(),
376 is_dir: *is_dir,
377 size: 1,
378 })
379 .collect(),
380 )
381 }
382
383 fn registry() -> RuleRegistry {
384 crate::builtin_registry()
385 }
386
387 fn eval_with(rule: &ForEachDirRule, files: &[(&str, bool)]) -> Vec<Violation> {
388 let idx = index(files);
389 let reg = registry();
390 let ctx = Context {
391 root: Path::new("/"),
392 index: &idx,
393 registry: Some(®),
394 facts: None,
395 vars: None,
396 git_tracked: None,
397 git_blame: None,
398 };
399 rule.evaluate(&ctx).unwrap()
400 }
401
402 fn rule(select: &str, require: Vec<NestedRuleSpec>) -> ForEachDirRule {
403 ForEachDirRule {
404 id: "t".into(),
405 level: Level::Error,
406 policy_url: None,
407 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
408 when_iter: None,
409 require,
410 }
411 }
412
413 fn require_file_exists(path: &str) -> NestedRuleSpec {
414 let yaml = format!("kind: file_exists\npaths: \"{path}\"\n");
416 serde_yaml_ng::from_str(&yaml).unwrap()
417 }
418
419 #[test]
420 fn passes_when_every_dir_has_required_file() {
421 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
422 let v = eval_with(
423 &r,
424 &[
425 ("src", true),
426 ("src/foo", true),
427 ("src/foo/mod.rs", false),
428 ("src/bar", true),
429 ("src/bar/mod.rs", false),
430 ],
431 );
432 assert!(v.is_empty(), "unexpected: {v:?}");
433 }
434
435 #[test]
436 fn violates_when_a_dir_missing_required_file() {
437 let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
438 let v = eval_with(
439 &r,
440 &[
441 ("src", true),
442 ("src/foo", true),
443 ("src/foo/mod.rs", false),
444 ("src/bar", true), ],
446 );
447 assert_eq!(v.len(), 1);
448 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/bar")));
449 }
450
451 #[test]
452 fn no_matched_dirs_means_no_violations() {
453 let r = rule("components/*", vec![require_file_exists("{dir}/index.tsx")]);
454 let v = eval_with(&r, &[("src", true), ("src/foo", true)]);
455 assert!(v.is_empty());
456 }
457
458 #[test]
459 fn every_require_rule_evaluated_per_dir() {
460 let r = rule(
461 "src/*",
462 vec![
463 require_file_exists("{path}/mod.rs"),
464 require_file_exists("{path}/README.md"),
465 ],
466 );
467 let v = eval_with(
468 &r,
469 &[
470 ("src", true),
471 ("src/foo", true),
472 ("src/foo/mod.rs", false), ],
474 );
475 assert_eq!(v.len(), 1);
476 assert!(
477 v[0].message.contains("README"),
478 "expected README in message; got {:?}",
479 v[0].message
480 );
481 }
482
483 #[test]
484 fn build_rejects_scope_filter_on_cross_file_rule() {
485 let yaml = r#"
490id: t
491kind: for_each_dir
492select: "src/*"
493require:
494 - kind: file_exists
495 paths: "{path}/mod.rs"
496level: error
497scope_filter:
498 has_ancestor: Cargo.toml
499"#;
500 let spec = crate::test_support::spec_yaml(yaml);
501 let err = build(&spec).unwrap_err().to_string();
502 assert!(
503 err.contains("scope_filter is supported on per-file rules only"),
504 "expected per-file-only message, got: {err}",
505 );
506 assert!(
507 err.contains("for_each_dir"),
508 "expected message to name the cross-file kind, got: {err}",
509 );
510 }
511}