Skip to main content

alint_rules/
every_matching_has.rs

1//! `every_matching_has` — every file OR directory matching `select` must
2//! satisfy every rule in `require`. Sugar over `for_each_file` +
3//! `for_each_dir`: one rule that iterates both entry kinds so users who
4//! don't care whether a glob matches files or dirs can write a single
5//! rule instead of two.
6//!
7//! ```yaml
8//! - id: every-pkg-has-readme
9//!   kind: every_matching_has
10//!   select: "packages/*"   # matches dirs today; might also match files tomorrow
11//!   require:
12//!     - kind: file_exists
13//!       paths: "{path}/README.md"
14//!   level: error
15//! ```
16
17use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
18use serde::Deserialize;
19
20use crate::for_each_dir::{IterateMode, evaluate_for_each};
21
22#[derive(Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24struct Options {
25    select: String,
26    require: Vec<NestedRuleSpec>,
27}
28
29#[derive(Debug)]
30pub struct EveryMatchingHasRule {
31    id: String,
32    level: Level,
33    policy_url: Option<String>,
34    select_scope: Scope,
35    require: Vec<NestedRuleSpec>,
36}
37
38impl Rule for EveryMatchingHasRule {
39    fn id(&self) -> &str {
40        &self.id
41    }
42    fn level(&self) -> Level {
43        self.level
44    }
45    fn policy_url(&self) -> Option<&str> {
46        self.policy_url.as_deref()
47    }
48
49    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
50        evaluate_for_each(
51            &self.id,
52            self.level,
53            &self.select_scope,
54            &self.require,
55            ctx,
56            IterateMode::Both,
57        )
58    }
59}
60
61pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
62    let opts: Options = spec
63        .deserialize_options()
64        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
65    if opts.require.is_empty() {
66        return Err(Error::rule_config(
67            &spec.id,
68            "every_matching_has requires at least one nested rule under `require:`",
69        ));
70    }
71    let select_scope = Scope::from_patterns(&[opts.select])?;
72    Ok(Box::new(EveryMatchingHasRule {
73        id: spec.id.clone(),
74        level: spec.level,
75        policy_url: spec.policy_url.clone(),
76        select_scope,
77        require: opts.require,
78    }))
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use alint_core::{FileEntry, FileIndex, RuleRegistry};
85    use std::path::{Path, PathBuf};
86
87    fn index(entries: &[(&str, bool)]) -> FileIndex {
88        FileIndex {
89            entries: entries
90                .iter()
91                .map(|(p, is_dir)| FileEntry {
92                    path: PathBuf::from(p),
93                    is_dir: *is_dir,
94                    size: 1,
95                })
96                .collect(),
97        }
98    }
99
100    fn registry() -> RuleRegistry {
101        crate::builtin_registry()
102    }
103
104    #[test]
105    fn iterates_both_files_and_dirs() {
106        // `packages/*` matches a dir `packages/a` AND a file `packages/x.md`
107        // (rare but possible). The rule should evaluate require against
108        // both.
109        let require: Vec<NestedRuleSpec> =
110            vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
111        let r = EveryMatchingHasRule {
112            id: "t".into(),
113            level: Level::Error,
114            policy_url: None,
115            select_scope: Scope::from_patterns(&["packages/*".to_string()]).unwrap(),
116            require,
117        };
118        let idx = index(&[
119            ("packages", true),
120            ("packages/a", true),
121            ("packages/x.md", false),
122        ]);
123        let reg = registry();
124        let ctx = Context {
125            root: Path::new("/"),
126            index: &idx,
127            registry: Some(&reg),
128            facts: None,
129            vars: None,
130        };
131        let v = r.evaluate(&ctx).unwrap();
132        // `{path}` resolves to "packages/a" (dir) and "packages/x.md" (file).
133        // The dir "packages/a" is not a file in the index — file_exists
134        // cannot find it because file_exists iterates files(), not dirs().
135        // So we expect one violation for the dir case and none for the file.
136        assert_eq!(v.len(), 1);
137        assert_eq!(v[0].path.as_deref(), Some(Path::new("packages/a")));
138    }
139}