checkleft 0.1.0-alpha.8

Experimental repository convention checker; API and behavior may change without notice
Documentation
use std::path::Path;
use std::sync::Arc;

use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use globset::{Glob, GlobMatcher};
use serde::Deserialize;

use crate::check::{Check, ConfiguredCheck};
use crate::input::{ChangeKind, ChangeSet, SourceTree};
use crate::output::{CheckResult, Finding, Location, Severity};

#[derive(Debug, Default)]
pub(crate) struct BazelversionPoliciesCheck;

#[async_trait]
impl Check for BazelversionPoliciesCheck {
    fn id(&self) -> &str {
        "bazelversion-policies"
    }

    fn description(&self) -> &str {
        "flags configured .bazelversion policy violations in changed files"
    }

    fn configure(&self, config: &toml::Value) -> Result<Arc<dyn ConfiguredCheck>> {
        Ok(Arc::new(parse_config(config)?))
    }
}

#[async_trait]
impl ConfiguredCheck for CompiledBazelversionPoliciesConfig {
    async fn run(&self, changeset: &ChangeSet, tree: &dyn SourceTree) -> Result<CheckResult> {
        let mut findings = Vec::new();

        for changed_file in &changeset.changed_files {
            if matches!(changed_file.kind, ChangeKind::Deleted) {
                continue;
            }
            if changed_file.path != Path::new(".bazelversion") {
                continue;
            }

            let Ok(contents) = tree.read_file(&changed_file.path) else {
                continue;
            };
            let Ok(contents) = std::str::from_utf8(&contents) else {
                continue;
            };

            let version = contents.trim();
            for rule in &self.rules {
                if let Some(finding) = rule.evaluate(&changed_file.path, version) {
                    findings.push(finding);
                }
            }
        }

        Ok(CheckResult {
            check_id: "bazelversion-policies".to_owned(),
            findings,
        })
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BazelversionPoliciesConfig {
    #[serde(default)]
    rules: Vec<BazelversionPolicyRuleConfig>,
    #[serde(default)]
    severity: Option<String>,
    #[serde(default)]
    remediation: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum BazelversionPolicyRuleConfig {
    AllowedVersionPatterns {
        patterns: Vec<String>,
        #[serde(default)]
        message: Option<String>,
        #[serde(default)]
        remediation: Option<String>,
        #[serde(default)]
        severity: Option<String>,
    },
}

#[derive(Debug)]
struct CompiledBazelversionPoliciesConfig {
    rules: Vec<CompiledRule>,
}

#[derive(Debug)]
enum CompiledRule {
    AllowedVersionPatterns(CompiledAllowedVersionPatternsRule),
}

#[derive(Debug)]
struct CompiledAllowedVersionPatternsRule {
    pattern_strings: Vec<String>,
    patterns: Vec<GlobMatcher>,
    message: Option<String>,
    remediation: Option<String>,
    severity: Severity,
}

impl CompiledRule {
    fn evaluate(&self, path: &Path, version: &str) -> Option<Finding> {
        match self {
            Self::AllowedVersionPatterns(rule) => rule.evaluate(path, version),
        }
    }
}

impl CompiledAllowedVersionPatternsRule {
    fn evaluate(&self, path: &Path, version: &str) -> Option<Finding> {
        if self
            .patterns
            .iter()
            .any(|pattern| pattern.is_match(Path::new(version)))
        {
            return None;
        }

        Some(Finding {
            severity: self.severity,
            message: self.message.clone().unwrap_or_else(|| {
                format!(
                    "`.bazelversion` value `{version}` must match one of: {}.",
                    self.pattern_strings
                        .iter()
                        .map(|pattern| format!("`{pattern}`"))
                        .collect::<Vec<_>>()
                        .join(", ")
                )
            }),
            location: Some(Location {
                path: path.to_path_buf(),
                line: Some(1),
                column: Some(1),
            }),
            remediation: Some(self.remediation.clone().unwrap_or_else(|| {
                format!(
                    "Update `.bazelversion` so it matches one of the approved patterns: {}.",
                    self.pattern_strings.join(", ")
                )
            })),
            suggested_fix: None,
        })
    }
}

fn parse_config(config: &toml::Value) -> Result<CompiledBazelversionPoliciesConfig> {
    let parsed: BazelversionPoliciesConfig = config
        .clone()
        .try_into()
        .context("invalid bazelversion-policies check config")?;
    if parsed.rules.is_empty() {
        bail!("bazelversion-policies check config must contain at least one `rules` entry");
    }

    let default_severity =
        Severity::parse_with_default(parsed.severity.as_deref(), Severity::Error);
    let default_remediation = normalize_optional_string(parsed.remediation, "remediation")?;

    let mut rules = Vec::with_capacity(parsed.rules.len());
    for (index, rule) in parsed.rules.into_iter().enumerate() {
        let field_prefix = format!("rules[{index}]");
        rules.push(match rule {
            BazelversionPolicyRuleConfig::AllowedVersionPatterns {
                patterns,
                message,
                remediation,
                severity,
            } => {
                let pattern_strings = normalize_non_empty_unique_strings(
                    patterns,
                    &format!("{field_prefix}.patterns"),
                )?;
                let patterns =
                    compile_patterns(&format!("{field_prefix}.patterns"), &pattern_strings)?;
                CompiledRule::AllowedVersionPatterns(CompiledAllowedVersionPatternsRule {
                    pattern_strings,
                    patterns,
                    message: normalize_optional_string(
                        message,
                        &format!("{field_prefix}.message"),
                    )?,
                    remediation: normalize_optional_string(
                        remediation,
                        &format!("{field_prefix}.remediation"),
                    )?
                    .or_else(|| default_remediation.clone()),
                    severity: Severity::parse_with_default(severity.as_deref(), default_severity),
                })
            }
        });
    }

    Ok(CompiledBazelversionPoliciesConfig { rules })
}

fn normalize_optional_string(value: Option<String>, field_name: &str) -> Result<Option<String>> {
    let Some(value) = value else {
        return Ok(None);
    };
    let trimmed = value.trim();
    if trimmed.is_empty() {
        bail!("bazelversion-policies check config `{field_name}` must not be empty when present");
    }
    Ok(Some(trimmed.to_owned()))
}

fn normalize_non_empty_unique_strings(
    values: Vec<String>,
    field_name: &str,
) -> Result<Vec<String>> {
    if values.is_empty() {
        bail!("bazelversion-policies check config `{field_name}` must contain at least one value");
    }

    let mut seen = std::collections::HashSet::new();
    let mut output = Vec::with_capacity(values.len());
    for value in values {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            bail!(
                "bazelversion-policies check config `{field_name}` must not contain empty values"
            );
        }
        if seen.insert(trimmed.to_owned()) {
            output.push(trimmed.to_owned());
        }
    }
    Ok(output)
}

fn compile_patterns(field_name: &str, patterns: &[String]) -> Result<Vec<GlobMatcher>> {
    patterns
        .iter()
        .map(|pattern| {
            Glob::new(pattern)
                .with_context(|| format!("invalid `{field_name}` glob pattern: {pattern}"))
                .map(|glob| glob.compile_matcher())
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::Path;

    use tempfile::tempdir;

    use super::BazelversionPoliciesCheck;
    use crate::check::Check;
    use crate::input::{ChangeKind, ChangeSet, ChangedFile};
    use crate::source_tree::LocalSourceTree;

    #[tokio::test]
    async fn passes_when_version_matches_exact_pattern() {
        let temp = tempdir().expect("create temp dir");
        fs::write(temp.path().join(".bazelversion"), "channel:live\n").expect("write version");

        let check = BazelversionPoliciesCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".bazelversion").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(toml::toml! {
                    rules = [{ kind = "allowed_version_patterns", patterns = ["channel:live", "channel:alpha"] }]
                }),
            )
            .await
            .expect("run check");

        assert!(result.findings.is_empty());
    }

    #[tokio::test]
    async fn passes_when_version_matches_wildcard_pattern() {
        let temp = tempdir().expect("create temp dir");
        fs::write(temp.path().join(".bazelversion"), "8.4.0\n").expect("write version");

        let check = BazelversionPoliciesCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".bazelversion").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(toml::toml! {
                    rules = [{ kind = "allowed_version_patterns", patterns = ["channel:*", "8.*"] }]
                }),
            )
            .await
            .expect("run check");

        assert!(result.findings.is_empty());
    }

    #[tokio::test]
    async fn reports_version_that_does_not_match_allowed_patterns() {
        let temp = tempdir().expect("create temp dir");
        fs::write(temp.path().join(".bazelversion"), "channel:beta\n").expect("write version");

        let check = BazelversionPoliciesCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".bazelversion").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(toml::toml! {
                    rules = [{ kind = "allowed_version_patterns", patterns = ["channel:live", "channel:alpha", "8.*"] }]
                }),
            )
            .await
            .expect("run check");

        assert_eq!(result.findings.len(), 1);
        assert!(
            result.findings[0]
                .message
                .contains("must match one of: `channel:live`, `channel:alpha`, `8.*`")
        );
    }

    #[tokio::test]
    async fn rejects_invalid_glob_pattern() {
        let check = BazelversionPoliciesCheck;
        let err = match check.configure(&toml::Value::Table(toml::toml! {
            rules = [{ kind = "allowed_version_patterns", patterns = ["["] }]
        })) {
            Ok(_) => panic!("config should fail"),
            Err(err) => err,
        };

        assert!(
            err.to_string()
                .contains("invalid `rules[0].patterns` glob pattern: [")
        );
    }
}