main-spex 0.21.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::path::{Path, PathBuf};

use serde::Deserialize;
use strum::IntoStaticStr;

use super::id::RuleId;
use super::rule_config::RuleConfig;
use crate::error::Result;
use crate::support::acceptance_criteria_lines::with_source_lines;
use crate::support::commitment::Commitment;
use crate::support::yaml_dir::load_yaml_dir;

#[cfg(test)]
#[path = "rule_test.rs"]
mod rule_test;

pub const RULES_DIR: &str = "rules";

#[derive(Debug)]
pub struct Rule {
    pub id: RuleId,
    pub path: PathBuf,
    pub acceptance_criteria: Vec<AcceptanceCriterion>,
    pub checks: Vec<Check>,
}

impl Rule {
    pub fn script_dir(&self) -> PathBuf {
        self.path.with_extension("")
    }

    pub fn load_all(rules_dir: &Path) -> Result<Vec<Self>> {
        Ok(load_yaml_dir::<RuleConfig>(rules_dir)?
            .into_iter()
            .map(|file| Self::from_config(file.item, file.path, &file.raw))
            .collect())
    }

    fn from_config(config: RuleConfig, path: PathBuf, raw: &str) -> Self {
        Self {
            id: config.id,
            path,
            acceptance_criteria: with_source_lines(
                config.acceptance_criteria,
                raw,
                |text, line| AcceptanceCriterion { text, line },
            ),
            checks: config.checks,
        }
    }
}

#[derive(Debug, Clone)]
pub struct AcceptanceCriterion {
    pub text: String,
    pub line: usize,
}

impl AcceptanceCriterion {
    pub fn commitment(&self) -> Option<Commitment> {
        Commitment::from_text(&self.text)
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct Check {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(flatten)]
    pub kind: CheckKind,
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
    #[serde(default)]
    pub severity: Severity,
}

impl Check {
    pub fn message(&self, default: &str) -> String {
        self.description
            .clone()
            .unwrap_or_else(|| default.to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum CheckKind {
    Regexp {
        pattern: String,
        #[serde(default)]
        negate: bool,
    },
    Glob {
        pattern: String,
        #[serde(default)]
        negate: bool,
    },
    AstGrep {
        #[serde(default)]
        pattern: Option<String>,
        #[serde(default)]
        rule: Option<serde_yaml_ng::Value>,
        #[serde(default)]
        language: Option<String>,
        #[serde(default)]
        negate: bool,
    },
    Script {
        script: String,
        #[serde(default)]
        run: RunScope,
    },
}

impl CheckKind {
    pub fn negate(&self) -> bool {
        match self {
            CheckKind::Regexp { negate, .. }
            | CheckKind::Glob { negate, .. }
            | CheckKind::AstGrep { negate, .. } => *negate,
            CheckKind::Script { .. } => false,
        }
    }

    pub fn is_project_scoped(&self) -> bool {
        matches!(
            self,
            CheckKind::Script {
                run: RunScope::Project,
                ..
            }
        )
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RunScope {
    #[default]
    PerFile,
    PerPackage,
    Project,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, IntoStaticStr)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum Severity {
    Warn,
    #[default]
    Error,
}