main-spex 0.21.0

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

use eros::{Context, IntoAnyUnion};
use serde::{Deserialize, Serialize};

use super::Layout;
use crate::error::{Result, config_not_found, could_not_parse, could_not_read};
use crate::rule::RuleId;
use crate::spec::SpecCoverageIgnore;
use crate::support::fs::dir_name;

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

pub const SPEX_CONFIG_FILE: &str = "spex.yaml";
pub const SPEX_DIR_NAME: &str = "spex";
pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");

#[derive(Debug, Deserialize, Serialize)]
pub struct SpexConfig {
    pub project_name: String,
    pub cli_version: String,
    pub layout: Layout,
    pub agent_root: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default)]
    pub rules_unused: Vec<RuleId>,
    #[serde(default)]
    pub packages: Vec<PackageEntry>,
    #[serde(default)]
    pub coverage_ignore: Vec<SpecCoverageIgnore>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PackageEntry {
    pub package_name: String,
    pub package_short_name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub path: String,
    #[serde(default)]
    pub contracts: Vec<ContractConfig>,
    #[serde(default)]
    pub rules: Vec<RuleId>,
    #[serde(default)]
    pub ignored_errors: Vec<PreexistingError>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ContractConfig {
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PreexistingError {
    pub rule_id: String,
    pub check: String,
    #[serde(default)]
    pub files: Vec<String>,
}

impl SpexConfig {
    pub fn find_file(start: &Path) -> Result<PathBuf> {
        for dir in start.ancestors() {
            let config_path = dir.join(SPEX_CONFIG_FILE);
            if config_path.is_file() {
                return Ok(config_path);
            }
            if let Some(child_config_path) = Self::spex_child_config_path(dir) {
                return Ok(child_config_path);
            }
        }
        Err(config_not_found())
    }

    pub fn dir_of(config_path: &Path) -> &Path {
        config_path.parent().unwrap_or(Path::new(""))
    }

    pub fn is_spex_dir_name(name: &str) -> bool {
        name == SPEX_DIR_NAME || name.ends_with(&format!("-{SPEX_DIR_NAME}"))
    }

    fn spex_child_config_path(dir: &Path) -> Option<PathBuf> {
        let mut config_paths: Vec<PathBuf> = fs::read_dir(dir)
            .ok()?
            .filter_map(|entry| entry.ok())
            .map(|entry| entry.path())
            .filter(|path| dir_name(path).is_some_and(Self::is_spex_dir_name))
            .map(|path| path.join(SPEX_CONFIG_FILE))
            .filter(|config_path| config_path.is_file())
            .collect();
        config_paths.sort();
        config_paths.into_iter().next()
    }

    pub fn load(config_path: &Path) -> Result<Self> {
        if !config_path.exists() {
            return Err(config_not_found());
        }

        let raw = fs::read_to_string(config_path).with_context(|| could_not_read(config_path))?;

        serde_yaml_ng::from_str(&raw)
            .with_context(|| could_not_parse(config_path))
            .any_union()
    }

    pub fn version_warning(&self) -> Option<String> {
        if self.cli_version == CLI_VERSION {
            return None;
        }
        Some(format!(
            "`{SPEX_CONFIG_FILE}` was written by spex {}, but the installed CLI is {CLI_VERSION} — run `spex update` to sync",
            self.cli_version
        ))
    }
}