Skip to main content

alint_rules/
for_each_dir.rs

1//! `for_each_dir` — iterate over every directory matching `select:` and
2//! evaluate a nested `require:` block against each. Path-template tokens
3//! in the nested specs are pre-substituted per iteration using the
4//! iterated directory as the anchor.
5//!
6//! Token conventions (shared with `for_each_file` and `pair`):
7//!
8//! - `{path}` — full relative path of the iterated entry.
9//! - `{dir}`  — parent directory of the iterated entry.
10//! - `{basename}` — name of the iterated entry.
11//! - `{stem}` — name with the final extension stripped.
12//! - `{ext}` — final extension without the dot.
13//! - `{parent_name}` — name of the entry's parent directory.
14//!
15//! When iterating *directories*, use `{path}` to name the iterated dir
16//! itself (e.g. `"{path}/mod.rs"` to require a `mod.rs` inside it). Use
17//! `{dir}` only when you need the parent of the matched entry.
18//!
19//! Canonical shape — for every direct subdirectory of `src/`, require a
20//! `mod.rs`:
21//!
22//! ```yaml
23//! - id: every-module-has-mod
24//!   kind: for_each_dir
25//!   select: "src/*"
26//!   require:
27//!     - kind: file_exists
28//!       paths: "{path}/mod.rs"
29//!   level: error
30//! ```
31
32use 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    /// Optional per-iteration filter — evaluated against each
42    /// iterated entry's `iter` context. Common shape:
43    /// `iter.has_file("Cargo.toml")` to scope the iteration to
44    /// directories that look like a workspace member.
45    #[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        // Cross-file: per-directory verdicts depend on what's in
73        // each iterated dir as a whole, not just changed entries.
74        // A `for_each_dir` over `src/*` requiring `mod.rs` must
75        // see every `src/*` even if only one file inside it
76        // changed. Per roadmap, opts out of `--changed` filtering.
77        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
116/// Compile a `when_iter:` source string into a `WhenExpr` at
117/// rule-build time. Public to the crate so the sibling
118/// `for_each_file` and `every_matching_has` rules can reuse the
119/// same error shape.
120pub(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/// What to iterate in [`evaluate_for_each`].
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub(crate) enum IterateMode {
130    Dirs,
131    Files,
132    /// Both files and dirs (dirs first) — used by `every_matching_has`.
133    Both,
134}
135
136/// Shared evaluation logic for `for_each_dir`, `for_each_file`, and
137/// `every_matching_has`. `mode` selects which entries to iterate.
138/// `when_iter` (compiled at rule-build time) gates each iteration:
139/// when present and false for an entry, that entry is skipped
140/// before any nested rule is built or evaluated.
141pub(crate) fn evaluate_for_each(
142    parent_id: &str,
143    level: Level,
144    select_scope: &Scope,
145    when_iter: Option<&WhenExpr>,
146    require: &[NestedRuleSpec],
147    ctx: &Context<'_>,
148    mode: IterateMode,
149) -> Result<Vec<Violation>> {
150    let Some(registry) = ctx.registry else {
151        return Err(Error::Other(format!(
152            "rule {parent_id}: nested-rule evaluation needs a RuleRegistry in the Context \
153             (likely an Engine constructed without one)",
154        )));
155    };
156
157    let entries: Box<dyn Iterator<Item = _>> = match mode {
158        IterateMode::Dirs => Box::new(ctx.index.dirs()),
159        IterateMode::Files => Box::new(ctx.index.files()),
160        IterateMode::Both => Box::new(ctx.index.dirs().chain(ctx.index.files())),
161    };
162
163    let mut violations = Vec::new();
164    for entry in entries {
165        if !select_scope.matches(&entry.path) {
166            continue;
167        }
168
169        // Per-iteration `when_iter:` filter. Cheap to evaluate
170        // (one IterEnv build + one expression walk per matched
171        // entry); skips the nested-rule build entirely on a
172        // false verdict, which is the whole point of the field.
173        let iter_env = IterEnv {
174            path: &entry.path,
175            is_dir: entry.is_dir,
176            index: ctx.index,
177        };
178        if let Some(expr) = when_iter {
179            if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
180                let env = alint_core::WhenEnv {
181                    facts,
182                    vars,
183                    iter: Some(iter_env),
184                };
185                match expr.evaluate(&env) {
186                    Ok(true) => {}
187                    Ok(false) => continue,
188                    Err(e) => {
189                        violations.push(
190                            Violation::new(format!("{parent_id}: when_iter error: {e}"))
191                                .with_path(entry.path.clone()),
192                        );
193                        continue;
194                    }
195                }
196            }
197        }
198
199        let tokens = PathTokens::from_path(&entry.path);
200        for (i, nested) in require.iter().enumerate() {
201            let nested_spec = nested.instantiate(parent_id, i, level, &tokens);
202            // Gate the nested rule on its `when:` clause (if
203            // present). Same `iter.*` context is available, so a
204            // nested rule can reach back to the iteration just
205            // like the outer `when_iter:` does.
206            if let Some(when_src) = &nested_spec.when {
207                if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
208                    let expr = alint_core::when::parse(when_src).map_err(|e| {
209                        Error::rule_config(
210                            parent_id,
211                            format!("nested rule #{i}: invalid when: {e}"),
212                        )
213                    })?;
214                    let env = alint_core::WhenEnv {
215                        facts,
216                        vars,
217                        iter: Some(iter_env),
218                    };
219                    match expr.evaluate(&env) {
220                        Ok(true) => {}
221                        Ok(false) => continue,
222                        Err(e) => {
223                            violations.push(
224                                Violation::new(format!(
225                                    "{parent_id}: nested rule #{i} when error: {e}"
226                                ))
227                                .with_path(entry.path.clone()),
228                            );
229                            continue;
230                        }
231                    }
232                }
233            }
234            let nested_rule = match registry.build(&nested_spec) {
235                Ok(r) => r,
236                Err(e) => {
237                    violations.push(
238                        Violation::new(format!(
239                            "{parent_id}: failed to build nested rule #{i} for {}: {e}",
240                            entry.path.display()
241                        ))
242                        .with_path(entry.path.clone()),
243                    );
244                    continue;
245                }
246            };
247            let nested_violations = nested_rule.evaluate(ctx)?;
248            for mut v in nested_violations {
249                if v.path.is_none() {
250                    v.path = Some(entry.path.clone());
251                }
252                violations.push(v);
253            }
254        }
255    }
256    Ok(violations)
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use alint_core::{FileEntry, FileIndex, RuleRegistry};
263    use std::path::Path;
264
265    fn index(entries: &[(&str, bool)]) -> FileIndex {
266        FileIndex::from_entries(
267            entries
268                .iter()
269                .map(|(p, is_dir)| FileEntry {
270                    path: std::path::Path::new(p).into(),
271                    is_dir: *is_dir,
272                    size: 1,
273                })
274                .collect(),
275        )
276    }
277
278    fn registry() -> RuleRegistry {
279        crate::builtin_registry()
280    }
281
282    fn eval_with(rule: &ForEachDirRule, files: &[(&str, bool)]) -> Vec<Violation> {
283        let idx = index(files);
284        let reg = registry();
285        let ctx = Context {
286            root: Path::new("/"),
287            index: &idx,
288            registry: Some(&reg),
289            facts: None,
290            vars: None,
291            git_tracked: None,
292            git_blame: None,
293        };
294        rule.evaluate(&ctx).unwrap()
295    }
296
297    fn rule(select: &str, require: Vec<NestedRuleSpec>) -> ForEachDirRule {
298        ForEachDirRule {
299            id: "t".into(),
300            level: Level::Error,
301            policy_url: None,
302            select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
303            when_iter: None,
304            require,
305        }
306    }
307
308    fn require_file_exists(path: &str) -> NestedRuleSpec {
309        // Build via YAML to exercise the same path production users take.
310        let yaml = format!("kind: file_exists\npaths: \"{path}\"\n");
311        serde_yaml_ng::from_str(&yaml).unwrap()
312    }
313
314    #[test]
315    fn passes_when_every_dir_has_required_file() {
316        let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
317        let v = eval_with(
318            &r,
319            &[
320                ("src", true),
321                ("src/foo", true),
322                ("src/foo/mod.rs", false),
323                ("src/bar", true),
324                ("src/bar/mod.rs", false),
325            ],
326        );
327        assert!(v.is_empty(), "unexpected: {v:?}");
328    }
329
330    #[test]
331    fn violates_when_a_dir_missing_required_file() {
332        let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
333        let v = eval_with(
334            &r,
335            &[
336                ("src", true),
337                ("src/foo", true),
338                ("src/foo/mod.rs", false),
339                ("src/bar", true), // no mod.rs
340            ],
341        );
342        assert_eq!(v.len(), 1);
343        assert_eq!(v[0].path.as_deref(), Some(Path::new("src/bar")));
344    }
345
346    #[test]
347    fn no_matched_dirs_means_no_violations() {
348        let r = rule("components/*", vec![require_file_exists("{dir}/index.tsx")]);
349        let v = eval_with(&r, &[("src", true), ("src/foo", true)]);
350        assert!(v.is_empty());
351    }
352
353    #[test]
354    fn every_require_rule_evaluated_per_dir() {
355        let r = rule(
356            "src/*",
357            vec![
358                require_file_exists("{path}/mod.rs"),
359                require_file_exists("{path}/README.md"),
360            ],
361        );
362        let v = eval_with(
363            &r,
364            &[
365                ("src", true),
366                ("src/foo", true),
367                ("src/foo/mod.rs", false), // has mod.rs, missing README
368            ],
369        );
370        assert_eq!(v.len(), 1);
371        assert!(
372            v[0].message.contains("README"),
373            "expected README in message; got {:?}",
374            v[0].message
375        );
376    }
377
378    #[test]
379    fn build_rejects_scope_filter_on_cross_file_rule() {
380        // for_each_dir is a cross-file rule (requires_full_index =
381        // true); scope_filter is per-file-rules-only. The build
382        // path must reject it with a clear message pointing at
383        // the for_each_dir + when_iter: alternative.
384        let yaml = r#"
385id: t
386kind: for_each_dir
387select: "src/*"
388require:
389  - kind: file_exists
390    paths: "{path}/mod.rs"
391level: error
392scope_filter:
393  has_ancestor: Cargo.toml
394"#;
395        let spec = crate::test_support::spec_yaml(yaml);
396        let err = build(&spec).unwrap_err().to_string();
397        assert!(
398            err.contains("scope_filter is supported on per-file rules only"),
399            "expected per-file-only message, got: {err}",
400        );
401        assert!(
402            err.contains("for_each_dir"),
403            "expected message to name the cross-file kind, got: {err}",
404        );
405    }
406}