Skip to main content

alint_rules/
for_each_file.rs

1//! `for_each_file` — iterate over every file matching `select:` and
2//! evaluate a nested `require:` block against each. Same mechanics as
3//! [`crate::for_each_dir`] — differs only in iterating files instead of
4//! directories from the `FileIndex`.
5//!
6//! Canonical shape — for every `tests/unit/*.rs`, require a corresponding
7//! `tests/snapshots/{stem}.snap`:
8//!
9//! ```yaml
10//! - id: unit-has-snapshot
11//!   kind: for_each_file
12//!   select: "tests/unit/*.rs"
13//!   require:
14//!     - kind: file_exists
15//!       paths: "tests/snapshots/{stem}.snap"
16//!   level: warning
17//! ```
18
19use 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    /// Optional per-iteration filter — typical shapes:
35    /// `iter.basename matches "^[a-z]"` to skip uppercase-named
36    /// files, or `not iter.has_file(...)` (always false for
37    /// file iteration) to no-op the rule.
38    #[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    fn id(&self) -> &str {
55        &self.id
56    }
57    fn level(&self) -> Level {
58        self.level
59    }
60    fn policy_url(&self) -> Option<&str> {
61        self.policy_url.as_deref()
62    }
63
64    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
65        evaluate_for_each(
66            &self.id,
67            self.level,
68            &self.select_scope,
69            self.when_iter.as_ref(),
70            &self.require,
71            ctx,
72            IterateMode::Files,
73        )
74    }
75}
76
77pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
78    alint_core::reject_scope_filter_on_cross_file(spec, "for_each_file")?;
79    let opts: Options = spec
80        .deserialize_options()
81        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
82    if opts.require.is_empty() {
83        return Err(Error::rule_config(
84            &spec.id,
85            "for_each_file requires at least one nested rule under `require:`",
86        ));
87    }
88    let select_scope = Scope::from_patterns(&[opts.select])?;
89    let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
90    let require = compile_nested_require(&spec.id, opts.require)?;
91    Ok(Box::new(ForEachFileRule {
92        id: spec.id.clone(),
93        level: spec.level,
94        policy_url: spec.policy_url.clone(),
95        select_scope,
96        when_iter,
97        require,
98    }))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use alint_core::{FileEntry, FileIndex, RuleRegistry};
105    use std::path::Path;
106
107    fn index(entries: &[(&str, bool)]) -> FileIndex {
108        FileIndex::from_entries(
109            entries
110                .iter()
111                .map(|(p, is_dir)| FileEntry {
112                    path: std::path::Path::new(p).into(),
113                    is_dir: *is_dir,
114                    size: 1,
115                })
116                .collect(),
117        )
118    }
119
120    fn registry() -> RuleRegistry {
121        crate::builtin_registry()
122    }
123
124    #[test]
125    fn passes_when_every_file_has_required_sibling() {
126        let require: Vec<NestedRuleSpec> = vec![
127            serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
128        ];
129        let require = compile_nested_require("t", require).unwrap();
130        let r = ForEachFileRule {
131            id: "t".into(),
132            level: Level::Error,
133            policy_url: None,
134            select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
135            when_iter: None,
136            require,
137        };
138        let idx = index(&[
139            ("src/foo.c", false),
140            ("src/foo.h", false),
141            ("src/bar.c", false),
142            ("src/bar.h", false),
143        ]);
144        let reg = registry();
145        let ctx = Context {
146            root: Path::new("/"),
147            index: &idx,
148            registry: Some(&reg),
149            facts: None,
150            vars: None,
151            git_tracked: None,
152            git_blame: None,
153        };
154        let v = r.evaluate(&ctx).unwrap();
155        assert!(v.is_empty(), "unexpected: {v:?}");
156    }
157
158    #[test]
159    fn violates_per_missing_sibling() {
160        let require: Vec<NestedRuleSpec> = vec![
161            serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
162        ];
163        let require = compile_nested_require("t", require).unwrap();
164        let r = ForEachFileRule {
165            id: "t".into(),
166            level: Level::Error,
167            policy_url: None,
168            select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
169            when_iter: None,
170            require,
171        };
172        let idx = index(&[
173            ("src/foo.c", false),
174            ("src/foo.h", false), // matched
175            ("src/bar.c", false), // no bar.h
176            ("src/baz.c", false), // no baz.h
177        ]);
178        let reg = registry();
179        let ctx = Context {
180            root: Path::new("/"),
181            index: &idx,
182            registry: Some(&reg),
183            facts: None,
184            vars: None,
185            git_tracked: None,
186            git_blame: None,
187        };
188        let v = r.evaluate(&ctx).unwrap();
189        assert_eq!(v.len(), 2);
190    }
191
192    #[test]
193    fn build_rejects_scope_filter_on_cross_file_rule() {
194        // for_each_file is a cross-file rule (requires_full_index
195        // = true); scope_filter is per-file-rules-only. The build
196        // path must reject it with a clear message pointing at
197        // the for_each_dir + when_iter: alternative.
198        let yaml = r#"
199id: t
200kind: for_each_file
201select: "**/*.c"
202require:
203  - kind: file_exists
204    paths: "{dir}/{stem}.h"
205level: error
206scope_filter:
207  has_ancestor: Cargo.toml
208"#;
209        let spec = crate::test_support::spec_yaml(yaml);
210        let err = build(&spec).unwrap_err().to_string();
211        assert!(
212            err.contains("scope_filter is supported on per-file rules only"),
213            "expected per-file-only message, got: {err}",
214        );
215        assert!(
216            err.contains("for_each_file"),
217            "expected message to name the cross-file kind, got: {err}",
218        );
219    }
220}