devops-models 0.1.0

Typed serde models for DevOps configuration formats: Kubernetes, Docker Compose, GitLab CI, GitHub Actions, Prometheus, Alertmanager, Helm, Ansible, and OpenAPI.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabArtifacts {
    #[serde(default)]
    pub paths: Vec<String>,
    #[serde(default)]
    pub expire_in: Option<String>,
    #[serde(default)]
    pub when: Option<String>,
    #[serde(default)]
    pub reports: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabCache {
    #[serde(default)]
    pub key: Option<String>,
    #[serde(default)]
    pub paths: Vec<String>,
    #[serde(default)]
    pub policy: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabRule {
    #[serde(default, rename = "if")]
    pub condition: Option<String>,
    #[serde(default)]
    pub when: Option<String>,
    #[serde(default)]
    pub changes: Option<Vec<String>>,
    #[serde(default)]
    pub exists: Option<Vec<String>>,
    #[serde(default)]
    pub allow_failure: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabJob {
    #[serde(default)]
    pub stage: Option<String>,
    #[serde(default)]
    pub image: Option<String>,
    #[serde(default)]
    pub script: Vec<String>,
    #[serde(default)]
    pub before_script: Vec<String>,
    #[serde(default)]
    pub after_script: Vec<String>,
    #[serde(default)]
    pub artifacts: Option<GitLabArtifacts>,
    #[serde(default)]
    pub cache: Option<GitLabCache>,
    #[serde(default)]
    pub rules: Vec<GitLabRule>,
    #[serde(default)]
    pub needs: Vec<String>,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub variables: HashMap<String, String>,
    #[serde(default)]
    pub allow_failure: Option<bool>,
    #[serde(default)]
    pub retry: Option<serde_json::Value>,
    #[serde(default)]
    pub timeout: Option<String>,
    #[serde(default)]
    pub services: Vec<serde_json::Value>,
    #[serde(default)]
    pub only: Option<serde_json::Value>,
    #[serde(default)]
    pub except: Option<serde_json::Value>,
    #[serde(default)]
    pub environment: Option<serde_json::Value>,
    #[serde(default)]
    pub coverage: Option<String>,
    #[serde(default)]
    pub when: Option<String>,
    #[serde(default)]
    pub extends: Option<serde_json::Value>,
    #[serde(default)]
    pub dependencies: Option<Vec<String>>,
}

/// Top-level GitLab CI configuration.
/// Reserved keywords are extracted separately; remaining keys are job names.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabCI {
    #[serde(default)]
    pub stages: Vec<String>,
    #[serde(default)]
    pub image: Option<String>,
    #[serde(default)]
    pub variables: HashMap<String, serde_json::Value>,
    #[serde(default)]
    pub default: Option<serde_json::Value>,
    #[serde(default)]
    pub include: Option<serde_json::Value>,
    #[serde(default)]
    pub workflow: Option<serde_json::Value>,
    /// Jobs are stored separately since they can have arbitrary names
    #[serde(skip)]
    pub jobs: HashMap<String, GitLabJob>,
}

const RESERVED_KEYWORDS: &[&str] = &[
    "stages", "image", "variables", "default", "include", "workflow",
    "before_script", "after_script", "cache", "services",
];

impl GitLabCI {
    /// Parse a GitLab CI file from a serde_json::Value, extracting reserved keywords
    /// and treating remaining top-level keys as job definitions.
    pub fn from_value(value: &serde_json::Value) -> Result<Self, String> {
        let obj = value.as_object().ok_or("GitLab CI must be a YAML mapping")?;

        let mut ci: GitLabCI = serde_json::from_value(value.clone())
            .map_err(|e| format!("Failed to parse GitLab CI config: {e}"))?;

        // Extract jobs (non-reserved top-level keys)
        for (key, val) in obj {
            if !RESERVED_KEYWORDS.contains(&key.as_str()) && !key.starts_with('.') {
                match serde_json::from_value::<GitLabJob>(val.clone()) {
                    Ok(job) => { ci.jobs.insert(key.clone(), job); }
                    Err(e) => return Err(format!("Invalid job '{key}': {e}")),
                }
            }
        }

        Ok(ci)
    }

    /// Validate the GitLab CI configuration and return warnings/errors
    pub fn validate(&self) -> Vec<String> {
        let mut warnings = Vec::new();

        // Check that job stages reference declared stages
        for (name, job) in &self.jobs {
            if let Some(stage) = &job.stage
                && !self.stages.is_empty()
                && !self.stages.contains(stage)
            {
                warnings.push(format!(
                    "Job '{}': stage '{}' not declared in stages list",
                    name, stage
                ));
            }

            // Check that needs reference existing jobs
            for need in &job.needs {
                if !self.jobs.contains_key(need) {
                    warnings.push(format!(
                        "Job '{}': needs '{}' which is not defined",
                        name, need
                    ));
                }
            }

            // Check script is not empty
            if job.script.is_empty() {
                warnings.push(format!("Job '{}': script is empty", name));
            }
        }

        if self.stages.is_empty() && !self.jobs.is_empty() {
            warnings.push("No 'stages' defined — jobs may run in undefined order".to_string());
        }

        warnings
    }
}