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::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
21use serde::Deserialize;
22
23use crate::for_each_dir::{IterateMode, evaluate_for_each, parse_when_iter};
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Options {
28    select: String,
29    /// Optional per-iteration filter — typical shapes:
30    /// `iter.basename matches "^[a-z]"` to skip uppercase-named
31    /// files, or `not iter.has_file(...)` (always false for
32    /// file iteration) to no-op the rule.
33    #[serde(default)]
34    when_iter: Option<String>,
35    require: Vec<NestedRuleSpec>,
36}
37
38#[derive(Debug)]
39pub struct ForEachFileRule {
40    id: String,
41    level: Level,
42    policy_url: Option<String>,
43    select_scope: Scope,
44    when_iter: Option<WhenExpr>,
45    require: Vec<NestedRuleSpec>,
46}
47
48impl Rule for ForEachFileRule {
49    fn id(&self) -> &str {
50        &self.id
51    }
52    fn level(&self) -> Level {
53        self.level
54    }
55    fn policy_url(&self) -> Option<&str> {
56        self.policy_url.as_deref()
57    }
58
59    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
60        evaluate_for_each(
61            &self.id,
62            self.level,
63            &self.select_scope,
64            self.when_iter.as_ref(),
65            &self.require,
66            ctx,
67            IterateMode::Files,
68        )
69    }
70}
71
72pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
73    let opts: Options = spec
74        .deserialize_options()
75        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
76    if opts.require.is_empty() {
77        return Err(Error::rule_config(
78            &spec.id,
79            "for_each_file requires at least one nested rule under `require:`",
80        ));
81    }
82    let select_scope = Scope::from_patterns(&[opts.select])?;
83    let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
84    Ok(Box::new(ForEachFileRule {
85        id: spec.id.clone(),
86        level: spec.level,
87        policy_url: spec.policy_url.clone(),
88        select_scope,
89        when_iter,
90        require: opts.require,
91    }))
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use alint_core::{FileEntry, FileIndex, RuleRegistry};
98    use std::path::{Path, PathBuf};
99
100    fn index(entries: &[(&str, bool)]) -> FileIndex {
101        FileIndex {
102            entries: entries
103                .iter()
104                .map(|(p, is_dir)| FileEntry {
105                    path: PathBuf::from(p),
106                    is_dir: *is_dir,
107                    size: 1,
108                })
109                .collect(),
110        }
111    }
112
113    fn registry() -> RuleRegistry {
114        crate::builtin_registry()
115    }
116
117    #[test]
118    fn passes_when_every_file_has_required_sibling() {
119        let require: Vec<NestedRuleSpec> = vec![
120            serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
121        ];
122        let r = ForEachFileRule {
123            id: "t".into(),
124            level: Level::Error,
125            policy_url: None,
126            select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
127            when_iter: None,
128            require,
129        };
130        let idx = index(&[
131            ("src/foo.c", false),
132            ("src/foo.h", false),
133            ("src/bar.c", false),
134            ("src/bar.h", false),
135        ]);
136        let reg = registry();
137        let ctx = Context {
138            root: Path::new("/"),
139            index: &idx,
140            registry: Some(&reg),
141            facts: None,
142            vars: None,
143            git_tracked: None,
144        };
145        let v = r.evaluate(&ctx).unwrap();
146        assert!(v.is_empty(), "unexpected: {v:?}");
147    }
148
149    #[test]
150    fn violates_per_missing_sibling() {
151        let require: Vec<NestedRuleSpec> = vec![
152            serde_yaml_ng::from_str("kind: file_exists\npaths: \"{dir}/{stem}.h\"\n").unwrap(),
153        ];
154        let r = ForEachFileRule {
155            id: "t".into(),
156            level: Level::Error,
157            policy_url: None,
158            select_scope: Scope::from_patterns(&["**/*.c".to_string()]).unwrap(),
159            when_iter: None,
160            require,
161        };
162        let idx = index(&[
163            ("src/foo.c", false),
164            ("src/foo.h", false), // matched
165            ("src/bar.c", false), // no bar.h
166            ("src/baz.c", false), // no baz.h
167        ]);
168        let reg = registry();
169        let ctx = Context {
170            root: Path::new("/"),
171            index: &idx,
172            registry: Some(&reg),
173            facts: None,
174            vars: None,
175            git_tracked: None,
176        };
177        let v = r.evaluate(&ctx).unwrap();
178        assert_eq!(v.len(), 2);
179    }
180}