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    alint_core::rule_common_impl!();
49
50    fn requires_full_index(&self) -> bool {
51        // Cross-file: every entry matching `select` must satisfy
52        // `require`, regardless of whether it (or its required
53        // partners) was in the diff. Per roadmap, opts out of
54        // `--changed` filtering.
55        true
56    }
57
58    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
59        evaluate_for_each(
60            &self.id,
61            self.level,
62            &self.select_scope,
63            self.when_iter.as_ref(),
64            &self.require,
65            ctx,
66            IterateMode::Both,
67        )
68    }
69}
70
71pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
72    alint_core::reject_scope_filter_on_cross_file(spec, "every_matching_has")?;
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            "every_matching_has 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    let require = compile_nested_require(&spec.id, opts.require)?;
85    Ok(Box::new(EveryMatchingHasRule {
86        id: spec.id.clone(),
87        level: spec.level,
88        policy_url: spec.policy_url.clone(),
89        select_scope,
90        when_iter,
91        require,
92    }))
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use alint_core::{FileEntry, FileIndex, RuleRegistry};
99    use std::path::Path;
100
101    fn index(entries: &[(&str, bool)]) -> FileIndex {
102        FileIndex::from_entries(
103            entries
104                .iter()
105                .map(|(p, is_dir)| FileEntry {
106                    path: std::path::Path::new(p).into(),
107                    is_dir: *is_dir,
108                    size: 1,
109                })
110                .collect(),
111        )
112    }
113
114    fn registry() -> RuleRegistry {
115        crate::builtin_registry()
116    }
117
118    #[test]
119    fn iterates_both_files_and_dirs() {
120        // `packages/*` matches a dir `packages/a` AND a file `packages/x.md`
121        // (rare but possible). The rule should evaluate require against
122        // both.
123        let require: Vec<NestedRuleSpec> =
124            vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
125        let require = compile_nested_require("t", require).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
158    #[test]
159    fn build_rejects_scope_filter_on_cross_file_rule() {
160        // every_matching_has is a cross-file rule
161        // (requires_full_index = true); scope_filter is
162        // per-file-rules-only. The build path must reject it with
163        // a clear message pointing at the for_each_dir +
164        // when_iter: alternative.
165        let yaml = r#"
166id: t
167kind: every_matching_has
168select: "packages/*"
169require:
170  - kind: file_exists
171    paths: "{path}/README.md"
172level: error
173scope_filter:
174  has_ancestor: Cargo.toml
175"#;
176        let spec = crate::test_support::spec_yaml(yaml);
177        let err = build(&spec).unwrap_err().to_string();
178        assert!(
179            err.contains("scope_filter is supported on per-file rules only"),
180            "expected per-file-only message, got: {err}",
181        );
182        assert!(
183            err.contains("every_matching_has"),
184            "expected message to name the cross-file kind, got: {err}",
185        );
186    }
187}