runmat-config 0.6.2

Shared configuration schema and loaders for RunMat
Documentation
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::manifest::is_relative_without_parent;

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectTestConfig {
    pub roots: Vec<PathBuf>,
    pub suites: Vec<String>,
    pub jobs: Option<usize>,
    pub isolation: Option<ProjectTestIsolation>,
    #[serde(rename = "timeout-ms")]
    pub timeout_ms: Option<u64>,
    pub paths: Vec<PathBuf>,
    #[serde(rename = "environment-allowlist")]
    pub environment_allowlist: Vec<String>,
    pub capabilities: Vec<String>,
    pub resources: BTreeMap<String, u32>,
    pub artifacts: ProjectTestArtifacts,
    pub reports: Vec<ProjectTestReport>,
    pub coverage: ProjectTestCoverage,
    pub shard: Option<ProjectTestShard>,
    pub cluster: Option<ProjectTestCluster>,
}

impl ProjectTestConfig {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }

    pub(super) fn validation_messages(&self) -> Vec<String> {
        let mut messages = Vec::new();
        for (field, paths) in [("[test].roots", &self.roots), ("[test].paths", &self.paths)] {
            for path in paths {
                if !is_relative_without_parent(path) {
                    messages.push(format!(
                        "{field} entries must be relative and cannot contain `..`: {}",
                        path.display()
                    ));
                }
            }
        }
        for path in &self.coverage.roots {
            if !is_relative_without_parent(path) {
                messages.push(format!(
                    "[test.coverage].roots entries must be relative and cannot contain `..`: {}",
                    path.display()
                ));
            }
        }
        if self
            .coverage
            .exclude
            .iter()
            .any(|pattern| pattern.trim().is_empty())
        {
            messages.push("[test.coverage].exclude entries must be non-empty".into());
        }
        if self.jobs == Some(0) {
            messages.push("[test].jobs must be greater than zero".into());
        }
        if self.timeout_ms == Some(0) {
            messages.push("[test].timeout-ms must be greater than zero".into());
        }
        if self
            .environment_allowlist
            .iter()
            .any(|name| !valid_environment_name(name))
        {
            messages.push(
                "[test].environment-allowlist entries must be non-empty ASCII environment names"
                    .into(),
            );
        }
        if self.capabilities.iter().any(|name| name.trim().is_empty()) {
            messages.push("[test].capabilities entries must be non-empty".into());
        }
        if self
            .resources
            .iter()
            .any(|(name, amount)| name.trim().is_empty() || *amount == 0)
        {
            messages.push("[test].resources names and quantities must be non-zero".into());
        }
        if let Some(shard) = self.shard {
            if shard.count == 0 || shard.index >= shard.count {
                messages.push("[test.shard] requires count > 0 and index < count".into());
            }
        }
        if self.artifacts.max_runs == Some(0) {
            messages.push("[test.artifacts].max-runs must be greater than zero".into());
        }
        if let Some(cluster) = &self.cluster {
            if cluster.max_workers == Some(0) {
                messages.push("[test.cluster].max-workers must be greater than zero".into());
            }
            for (field, value) in [
                ("profile", cluster.profile.as_deref()),
                ("queue", cluster.queue.as_deref()),
            ] {
                if value.is_some_and(|value| value.trim().is_empty() || value.len() > 256) {
                    messages.push(format!(
                        "[test.cluster].{field} must be a non-empty value of at most 256 bytes"
                    ));
                }
            }
        }
        messages
    }

    pub fn resolved_roots(&self, project_root: &Path) -> Vec<PathBuf> {
        self.roots
            .iter()
            .map(|root| project_root.join(root))
            .collect()
    }
}

fn valid_environment_name(name: &str) -> bool {
    !name.is_empty()
        && name.is_ascii()
        && name
            .bytes()
            .all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProjectTestIsolation {
    Auto,
    Process,
    Worker,
    Session,
    None,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectTestArtifacts {
    #[serde(rename = "keep-successful")]
    pub keep_successful: bool,
    #[serde(rename = "keep-failed")]
    pub keep_failed: bool,
    #[serde(rename = "max-runs")]
    pub max_runs: Option<u32>,
}

impl Default for ProjectTestArtifacts {
    fn default() -> Self {
        Self {
            keep_successful: false,
            keep_failed: true,
            max_runs: None,
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProjectTestReport {
    Human,
    Json,
    Junit,
    Tap,
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectTestCoverage {
    pub enabled: bool,
    pub formats: Vec<ProjectCoverageFormat>,
    pub roots: Vec<PathBuf>,
    pub exclude: Vec<String>,
    #[serde(rename = "include-generated")]
    pub include_generated: bool,
    #[serde(rename = "include-vendor")]
    pub include_vendor: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProjectCoverageFormat {
    Json,
    Lcov,
    Cobertura,
    Html,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectTestShard {
    pub index: u32,
    pub count: u32,
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectTestCluster {
    pub profile: Option<String>,
    pub queue: Option<String>,
    #[serde(rename = "max-workers")]
    pub max_workers: Option<u32>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_host_independent_test_policy() {
        let config = ProjectTestConfig {
            jobs: Some(0),
            timeout_ms: Some(0),
            shard: Some(ProjectTestShard { index: 2, count: 2 }),
            environment_allowlist: vec!["BAD-NAME".into()],
            ..ProjectTestConfig::default()
        };
        let messages = config.validation_messages();
        assert_eq!(messages.len(), 4, "{messages:#?}");
    }

    #[test]
    fn validates_cluster_selection_and_capacity() {
        let config = ProjectTestConfig {
            cluster: Some(ProjectTestCluster {
                profile: Some(" ".into()),
                queue: Some("q".repeat(257)),
                max_workers: Some(0),
            }),
            ..ProjectTestConfig::default()
        };
        let messages = config.validation_messages();
        assert_eq!(messages.len(), 3, "{messages:#?}");
    }

    #[test]
    fn manifest_round_trips_complete_test_configuration() {
        let manifest = crate::project::parse_project_manifest_toml(
            r#"
[package]
name = "test-project"

[sources]
roots = ["src"]

[test]
roots = ["tests"]
suites = ["unit"]
jobs = 4
isolation = "auto"
timeout-ms = 30000
paths = ["test-support"]
environment-allowlist = ["CI", "RUNMAT_TEST_SEED"]
capabilities = ["gpu"]
reports = ["human", "json", "junit", "tap"]

[test.resources]
gpu = 1

[test.artifacts]
keep-failed = true
max-runs = 20

[test.coverage]
enabled = true
formats = ["json", "lcov", "cobertura", "html"]
roots = ["src"]
exclude = ["vendor/**"]
include-generated = false
include-vendor = false

[test.shard]
index = 1
count = 3

[test.cluster]
profile = "ci"
queue = "tests"
max-workers = 8
"#,
        )
        .unwrap();

        assert_eq!(manifest.test.jobs, Some(4));
        assert_eq!(manifest.test.isolation, Some(ProjectTestIsolation::Auto));
        assert_eq!(manifest.test.reports.len(), 4);
        assert_eq!(manifest.test.resources.get("gpu"), Some(&1));
        assert_eq!(
            manifest.test.coverage.formats,
            vec![
                ProjectCoverageFormat::Json,
                ProjectCoverageFormat::Lcov,
                ProjectCoverageFormat::Cobertura,
                ProjectCoverageFormat::Html,
            ]
        );
        let json = serde_json::to_string(&manifest).unwrap();
        let decoded = crate::project::parse_project_manifest_json(&json).unwrap();
        assert_eq!(decoded, manifest);
    }
}