alint-rules 0.4.5

Internal: built-in rule implementations for alint. Not a stable public API.
Documentation
//! `every_matching_has` — every file OR directory matching `select` must
//! satisfy every rule in `require`. Sugar over `for_each_file` +
//! `for_each_dir`: one rule that iterates both entry kinds so users who
//! don't care whether a glob matches files or dirs can write a single
//! rule instead of two.
//!
//! ```yaml
//! - id: every-pkg-has-readme
//!   kind: every_matching_has
//!   select: "packages/*"   # matches dirs today; might also match files tomorrow
//!   require:
//!     - kind: file_exists
//!       paths: "{path}/README.md"
//!   level: error
//! ```

use alint_core::{Context, Error, Level, NestedRuleSpec, Result, Rule, RuleSpec, Scope, Violation};
use serde::Deserialize;

use crate::for_each_dir::{IterateMode, evaluate_for_each};

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
    select: String,
    require: Vec<NestedRuleSpec>,
}

#[derive(Debug)]
pub struct EveryMatchingHasRule {
    id: String,
    level: Level,
    policy_url: Option<String>,
    select_scope: Scope,
    require: Vec<NestedRuleSpec>,
}

impl Rule for EveryMatchingHasRule {
    fn id(&self) -> &str {
        &self.id
    }
    fn level(&self) -> Level {
        self.level
    }
    fn policy_url(&self) -> Option<&str> {
        self.policy_url.as_deref()
    }

    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
        evaluate_for_each(
            &self.id,
            self.level,
            &self.select_scope,
            &self.require,
            ctx,
            IterateMode::Both,
        )
    }
}

pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
    let opts: Options = spec
        .deserialize_options()
        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
    if opts.require.is_empty() {
        return Err(Error::rule_config(
            &spec.id,
            "every_matching_has requires at least one nested rule under `require:`",
        ));
    }
    let select_scope = Scope::from_patterns(&[opts.select])?;
    Ok(Box::new(EveryMatchingHasRule {
        id: spec.id.clone(),
        level: spec.level,
        policy_url: spec.policy_url.clone(),
        select_scope,
        require: opts.require,
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use alint_core::{FileEntry, FileIndex, RuleRegistry};
    use std::path::{Path, PathBuf};

    fn index(entries: &[(&str, bool)]) -> FileIndex {
        FileIndex {
            entries: entries
                .iter()
                .map(|(p, is_dir)| FileEntry {
                    path: PathBuf::from(p),
                    is_dir: *is_dir,
                    size: 1,
                })
                .collect(),
        }
    }

    fn registry() -> RuleRegistry {
        crate::builtin_registry()
    }

    #[test]
    fn iterates_both_files_and_dirs() {
        // `packages/*` matches a dir `packages/a` AND a file `packages/x.md`
        // (rare but possible). The rule should evaluate require against
        // both.
        let require: Vec<NestedRuleSpec> =
            vec![serde_yaml_ng::from_str("kind: file_exists\npaths: \"{path}\"\n").unwrap()];
        let r = EveryMatchingHasRule {
            id: "t".into(),
            level: Level::Error,
            policy_url: None,
            select_scope: Scope::from_patterns(&["packages/*".to_string()]).unwrap(),
            require,
        };
        let idx = index(&[
            ("packages", true),
            ("packages/a", true),
            ("packages/x.md", false),
        ]);
        let reg = registry();
        let ctx = Context {
            root: Path::new("/"),
            index: &idx,
            registry: Some(&reg),
            facts: None,
            vars: None,
        };
        let v = r.evaluate(&ctx).unwrap();
        // `{path}` resolves to "packages/a" (dir) and "packages/x.md" (file).
        // The dir "packages/a" is not a file in the index — file_exists
        // cannot find it because file_exists iterates files(), not dirs().
        // So we expect one violation for the dir case and none for the file.
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].path.as_deref(), Some(Path::new("packages/a")));
    }
}