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::when::WhenExpr;
18use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
19use serde::Deserialize;
20
21use crate::for_each_dir::{IterateMode, evaluate_for_each, parse_when_iter};
22
23#[derive(Debug, Deserialize)]
24#[serde(deny_unknown_fields)]
25struct Options {
26    select: String,
27    #[serde(default)]
28    when_iter: Option<String>,
29    require: Vec<NestedRuleSpec>,
30}
31
32#[derive(Debug)]
33pub struct EveryMatchingHasRule {
34    id: String,
35    level: Level,
36    policy_url: Option<String>,
37    select_scope: Scope,
38    when_iter: Option<WhenExpr>,
39    require: Vec<NestedRuleSpec>,
40}
41
42impl Rule for EveryMatchingHasRule {
43    fn id(&self) -> &str {
44        &self.id
45    }
46    fn level(&self) -> Level {
47        self.level
48    }
49    fn policy_url(&self) -> Option<&str> {
50        self.policy_url.as_deref()
51    }
52
53    fn requires_full_index(&self) -> bool {
54        // Cross-file: every entry matching `select` must satisfy
55        // `require`, regardless of whether it (or its required
56        // partners) was in the diff. Per roadmap, opts out of
57        // `--changed` filtering.
58        true
59    }
60
61    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
62        evaluate_for_each(
63            &self.id,
64            self.level,
65            &self.select_scope,
66            self.when_iter.as_ref(),
67            &self.require,
68            ctx,
69            IterateMode::Both,
70        )
71    }
72}
73
74pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
75    let opts: Options = spec
76        .deserialize_options()
77        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
78    if opts.require.is_empty() {
79        return Err(Error::rule_config(
80            &spec.id,
81            "every_matching_has requires at least one nested rule under `require:`",
82        ));
83    }
84    let select_scope = Scope::from_patterns(&[opts.select])?;
85    let when_iter = parse_when_iter(spec, opts.when_iter.as_deref())?;
86    Ok(Box::new(EveryMatchingHasRule {
87        id: spec.id.clone(),
88        level: spec.level,
89        policy_url: spec.policy_url.clone(),
90        select_scope,
91        when_iter,
92        require: opts.require,
93    }))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use alint_core::{FileEntry, FileIndex, RuleRegistry};
100    use std::path::{Path, PathBuf};
101
102    fn index(entries: &[(&str, bool)]) -> FileIndex {
103        FileIndex {
104            entries: entries
105                .iter()
106                .map(|(p, is_dir)| FileEntry {
107                    path: PathBuf::from(p),
108                    is_dir: *is_dir,
109                    size: 1,
110                })
111                .collect(),
112        }
113    }
114
115    fn registry() -> RuleRegistry {
116        crate::builtin_registry()
117    }
118
119    #[test]
120    fn iterates_both_files_and_dirs() {
121        // `packages/*` matches a dir `packages/a` AND a file `packages/x.md`
122        // (rare but possible). The rule should evaluate require against
123        // both.
124        let require: Vec<NestedRuleSpec> =
125            vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
126        let r = EveryMatchingHasRule {
127            id: "t".into(),
128            level: Level::Error,
129            policy_url: None,
130            select_scope: Scope::from_patterns(&["packages/*".to_string()]).unwrap(),
131            when_iter: None,
132            require,
133        };
134        let idx = index(&[
135            ("packages", true),
136            ("packages/a", true),
137            ("packages/x.md", false),
138        ]);
139        let reg = registry();
140        let ctx = Context {
141            root: Path::new("/"),
142            index: &idx,
143            registry: Some(&reg),
144            facts: None,
145            vars: None,
146            git_tracked: None,
147            git_blame: None,
148        };
149        let v = r.evaluate(&ctx).unwrap();
150        // `{path}` resolves to "packages/a" (dir) and "packages/x.md" (file).
151        // The dir "packages/a" is not a file in the index — file_exists
152        // cannot find it because file_exists iterates files(), not dirs().
153        // So we expect one violation for the dir case and none for the file.
154        assert_eq!(v.len(), 1);
155        assert_eq!(v[0].path.as_deref(), Some(Path::new("packages/a")));
156    }
157}