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