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