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::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
34use serde::Deserialize;
35
36#[derive(Debug, Deserialize)]
37#[serde(deny_unknown_fields)]
38struct Options {
39    select: String,
40    require: Vec<NestedRuleSpec>,
41}
42
43#[derive(Debug)]
44pub struct ForEachDirRule {
45    id: String,
46    level: Level,
47    policy_url: Option<String>,
48    select_scope: Scope,
49    require: Vec<NestedRuleSpec>,
50}
51
52impl Rule for ForEachDirRule {
53    fn id(&self) -> &str {
54        &self.id
55    }
56    fn level(&self) -> Level {
57        self.level
58    }
59    fn policy_url(&self) -> Option<&str> {
60        self.policy_url.as_deref()
61    }
62
63    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
64        evaluate_for_each(
65            &self.id,
66            self.level,
67            &self.select_scope,
68            &self.require,
69            ctx,
70            IterateMode::Dirs,
71        )
72    }
73}
74
75pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
76    let opts: Options = spec
77        .deserialize_options()
78        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
79    if opts.require.is_empty() {
80        return Err(Error::rule_config(
81            &spec.id,
82            "for_each_dir requires at least one nested rule under `require:`",
83        ));
84    }
85    let select_scope = Scope::from_patterns(&[opts.select])?;
86    Ok(Box::new(ForEachDirRule {
87        id: spec.id.clone(),
88        level: spec.level,
89        policy_url: spec.policy_url.clone(),
90        select_scope,
91        require: opts.require,
92    }))
93}
94
95/// What to iterate in [`evaluate_for_each`].
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub(crate) enum IterateMode {
98    Dirs,
99    Files,
100    /// Both files and dirs (dirs first) — used by `every_matching_has`.
101    Both,
102}
103
104/// Shared evaluation logic for `for_each_dir`, `for_each_file`, and
105/// `every_matching_has`. `mode` selects which entries to iterate.
106pub(crate) fn evaluate_for_each(
107    parent_id: &str,
108    level: Level,
109    select_scope: &Scope,
110    require: &[NestedRuleSpec],
111    ctx: &Context<'_>,
112    mode: IterateMode,
113) -> Result<Vec<Violation>> {
114    let Some(registry) = ctx.registry else {
115        return Err(Error::Other(format!(
116            "rule {parent_id}: nested-rule evaluation needs a RuleRegistry in the Context \
117             (likely an Engine constructed without one)",
118        )));
119    };
120
121    let entries: Box<dyn Iterator<Item = _>> = match mode {
122        IterateMode::Dirs => Box::new(ctx.index.dirs()),
123        IterateMode::Files => Box::new(ctx.index.files()),
124        IterateMode::Both => Box::new(ctx.index.dirs().chain(ctx.index.files())),
125    };
126
127    let mut violations = Vec::new();
128    for entry in entries {
129        if !select_scope.matches(&entry.path) {
130            continue;
131        }
132        let tokens = PathTokens::from_path(&entry.path);
133        for (i, nested) in require.iter().enumerate() {
134            let nested_spec = nested.instantiate(parent_id, i, level, &tokens);
135            // Gate the nested rule on its `when:` clause (if present).
136            if let Some(when_src) = &nested_spec.when {
137                if let (Some(facts), Some(vars)) = (ctx.facts, ctx.vars) {
138                    let expr = alint_core::when::parse(when_src).map_err(|e| {
139                        Error::rule_config(
140                            parent_id,
141                            format!("nested rule #{i}: invalid when: {e}"),
142                        )
143                    })?;
144                    let env = alint_core::WhenEnv { facts, vars };
145                    match expr.evaluate(&env) {
146                        Ok(true) => {}
147                        Ok(false) => continue,
148                        Err(e) => {
149                            violations.push(
150                                Violation::new(format!(
151                                    "{parent_id}: nested rule #{i} when error: {e}"
152                                ))
153                                .with_path(&entry.path),
154                            );
155                            continue;
156                        }
157                    }
158                }
159            }
160            let nested_rule = match registry.build(&nested_spec) {
161                Ok(r) => r,
162                Err(e) => {
163                    violations.push(
164                        Violation::new(format!(
165                            "{parent_id}: failed to build nested rule #{i} for {}: {e}",
166                            entry.path.display()
167                        ))
168                        .with_path(&entry.path),
169                    );
170                    continue;
171                }
172            };
173            let nested_violations = nested_rule.evaluate(ctx)?;
174            for mut v in nested_violations {
175                if v.path.is_none() {
176                    v.path = Some(entry.path.clone());
177                }
178                violations.push(v);
179            }
180        }
181    }
182    Ok(violations)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use alint_core::{FileEntry, FileIndex, RuleRegistry};
189    use std::path::{Path, PathBuf};
190
191    fn index(entries: &[(&str, bool)]) -> FileIndex {
192        FileIndex {
193            entries: entries
194                .iter()
195                .map(|(p, is_dir)| FileEntry {
196                    path: PathBuf::from(p),
197                    is_dir: *is_dir,
198                    size: 1,
199                })
200                .collect(),
201        }
202    }
203
204    fn registry() -> RuleRegistry {
205        crate::builtin_registry()
206    }
207
208    fn eval_with(rule: &ForEachDirRule, files: &[(&str, bool)]) -> Vec<Violation> {
209        let idx = index(files);
210        let reg = registry();
211        let ctx = Context {
212            root: Path::new("/"),
213            index: &idx,
214            registry: Some(&reg),
215            facts: None,
216            vars: None,
217        };
218        rule.evaluate(&ctx).unwrap()
219    }
220
221    fn rule(select: &str, require: Vec<NestedRuleSpec>) -> ForEachDirRule {
222        ForEachDirRule {
223            id: "t".into(),
224            level: Level::Error,
225            policy_url: None,
226            select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
227            require,
228        }
229    }
230
231    fn require_file_exists(path: &str) -> NestedRuleSpec {
232        // Build via YAML to exercise the same path production users take.
233        let yaml = format!("kind: file_exists\npaths: \"{path}\"\n");
234        serde_yaml_ng::from_str(&yaml).unwrap()
235    }
236
237    #[test]
238    fn passes_when_every_dir_has_required_file() {
239        let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
240        let v = eval_with(
241            &r,
242            &[
243                ("src", true),
244                ("src/foo", true),
245                ("src/foo/mod.rs", false),
246                ("src/bar", true),
247                ("src/bar/mod.rs", false),
248            ],
249        );
250        assert!(v.is_empty(), "unexpected: {v:?}");
251    }
252
253    #[test]
254    fn violates_when_a_dir_missing_required_file() {
255        let r = rule("src/*", vec![require_file_exists("{path}/mod.rs")]);
256        let v = eval_with(
257            &r,
258            &[
259                ("src", true),
260                ("src/foo", true),
261                ("src/foo/mod.rs", false),
262                ("src/bar", true), // no mod.rs
263            ],
264        );
265        assert_eq!(v.len(), 1);
266        assert_eq!(v[0].path.as_deref(), Some(Path::new("src/bar")));
267    }
268
269    #[test]
270    fn no_matched_dirs_means_no_violations() {
271        let r = rule("components/*", vec![require_file_exists("{dir}/index.tsx")]);
272        let v = eval_with(&r, &[("src", true), ("src/foo", true)]);
273        assert!(v.is_empty());
274    }
275
276    #[test]
277    fn every_require_rule_evaluated_per_dir() {
278        let r = rule(
279            "src/*",
280            vec![
281                require_file_exists("{path}/mod.rs"),
282                require_file_exists("{path}/README.md"),
283            ],
284        );
285        let v = eval_with(
286            &r,
287            &[
288                ("src", true),
289                ("src/foo", true),
290                ("src/foo/mod.rs", false), // has mod.rs, missing README
291            ],
292        );
293        assert_eq!(v.len(), 1);
294        assert!(
295            v[0].message.contains("README"),
296            "expected README in message; got {:?}",
297            v[0].message
298        );
299    }
300}