devops-validate 0.1.0

YAML validation and auto-repair engine for DevOps configuration files: Kubernetes, Docker Compose, GitLab CI, GitHub Actions, Prometheus, Alertmanager, Helm, and Ansible.
Documentation
//! YAML type detection from content
//!
//! Simplified type detection that prioritizes explicit markers over heuristics.

use serde_json::Value;

/// Supported YAML configuration types for schema-based validation.
///
/// Variant names are self-documenting (`K8sDeployment`, `GitLabCI`, etc.).
/// Use [`detect_type`] to obtain a value from parsed YAML content, then
/// convert to a schema key with [`to_schema_key`](YamlType::to_schema_key).
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum YamlType {
    // Kubernetes
    K8sDeployment,
    K8sService,
    K8sConfigMap,
    K8sSecret,
    K8sIngress,
    K8sHPA,
    K8sCronJob,
    K8sJob,
    K8sPVC,
    K8sNetworkPolicy,
    K8sStatefulSet,
    K8sDaemonSet,
    K8sRole,
    K8sClusterRole,
    K8sRoleBinding,
    K8sClusterRoleBinding,
    K8sServiceAccount,
    K8sGeneric,

    // CI/CD
    GitLabCI,
    GitHubActions,

    // Containers
    DockerCompose,

    // Monitoring
    Prometheus,
    Alertmanager,

    // Configuration
    HelmValues,
    Ansible,
    OpenAPI,

    // Fallback
    Generic,
}

impl YamlType {
    /// Convert to schema registry key (e.g., "k8s/deployment")
    pub fn to_schema_key(&self) -> &'static str {
        match self {
            YamlType::K8sDeployment => "k8s/deployment",
            YamlType::K8sService => "k8s/service",
            YamlType::K8sConfigMap => "k8s/configmap",
            YamlType::K8sSecret => "k8s/secret",
            YamlType::K8sIngress => "k8s/ingress",
            YamlType::K8sHPA => "k8s/horizontalpodautoscaler",
            YamlType::K8sCronJob => "k8s/cronjob",
            YamlType::K8sJob => "k8s/job",
            YamlType::K8sPVC => "k8s/persistentvolumeclaim",
            YamlType::K8sNetworkPolicy => "k8s/networkpolicy",
            YamlType::K8sStatefulSet => "k8s/statefulset",
            YamlType::K8sDaemonSet => "k8s/daemonset",
            YamlType::K8sRole => "k8s/role",
            YamlType::K8sClusterRole => "k8s/clusterrole",
            YamlType::K8sRoleBinding => "k8s/rolebinding",
            YamlType::K8sClusterRoleBinding => "k8s/clusterrolebinding",
            YamlType::K8sServiceAccount => "k8s/serviceaccount",
            YamlType::K8sGeneric => "k8s/generic",

            YamlType::GitLabCI => "gitlab-ci",
            YamlType::GitHubActions => "github-actions",
            YamlType::DockerCompose => "docker-compose",
            YamlType::Prometheus => "prometheus",
            YamlType::Alertmanager => "alertmanager",
            YamlType::HelmValues => "helm-values",
            YamlType::Ansible => "ansible",
            YamlType::OpenAPI => "openapi",

            YamlType::Generic => "generic",
        }
    }

    /// Check if this is a Kubernetes type
    pub fn is_kubernetes(&self) -> bool {
        matches!(
            self,
            YamlType::K8sDeployment
                | YamlType::K8sService
                | YamlType::K8sConfigMap
                | YamlType::K8sSecret
                | YamlType::K8sIngress
                | YamlType::K8sHPA
                | YamlType::K8sCronJob
                | YamlType::K8sJob
                | YamlType::K8sPVC
                | YamlType::K8sNetworkPolicy
                | YamlType::K8sStatefulSet
                | YamlType::K8sDaemonSet
                | YamlType::K8sRole
                | YamlType::K8sClusterRole
                | YamlType::K8sRoleBinding
                | YamlType::K8sClusterRoleBinding
                | YamlType::K8sServiceAccount
                | YamlType::K8sGeneric
        )
    }

    /// Get display name for UI
    pub fn display_name(&self) -> &'static str {
        match self {
            YamlType::K8sDeployment => "Kubernetes Deployment",
            YamlType::K8sService => "Kubernetes Service",
            YamlType::K8sConfigMap => "Kubernetes ConfigMap",
            YamlType::K8sSecret => "Kubernetes Secret",
            YamlType::K8sIngress => "Kubernetes Ingress",
            YamlType::K8sHPA => "Kubernetes HPA",
            YamlType::K8sCronJob => "Kubernetes CronJob",
            YamlType::K8sJob => "Kubernetes Job",
            YamlType::K8sPVC => "Kubernetes PVC",
            YamlType::K8sNetworkPolicy => "Kubernetes NetworkPolicy",
            YamlType::K8sStatefulSet => "Kubernetes StatefulSet",
            YamlType::K8sDaemonSet => "Kubernetes DaemonSet",
            YamlType::K8sRole => "Kubernetes Role",
            YamlType::K8sClusterRole => "Kubernetes ClusterRole",
            YamlType::K8sRoleBinding => "Kubernetes RoleBinding",
            YamlType::K8sClusterRoleBinding => "Kubernetes ClusterRoleBinding",
            YamlType::K8sServiceAccount => "Kubernetes ServiceAccount",
            YamlType::K8sGeneric => "Kubernetes Resource",
            YamlType::GitLabCI => "GitLab CI",
            YamlType::GitHubActions => "GitHub Actions",
            YamlType::DockerCompose => "Docker Compose",
            YamlType::Prometheus => "Prometheus Config",
            YamlType::Alertmanager => "Alertmanager Config",
            YamlType::HelmValues => "Helm Values",
            YamlType::Ansible => "Ansible Playbook",
            YamlType::OpenAPI => "OpenAPI Spec",
            YamlType::Generic => "Generic YAML",
        }
    }
}

impl std::fmt::Display for YamlType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.display_name())
    }
}

/// Detect YAML type from parsed content.
///
/// This function uses a schema-first approach:
/// 1. Check for explicit `$schema` field
/// 2. Check for Kubernetes `apiVersion` + `kind`
/// 3. Check for CI/CD patterns
/// 4. Fallback to Generic
pub fn detect_type(data: &Value) -> YamlType {
    let obj = match data.as_object() {
        Some(o) => o,
        None => {
            // Top-level array could be Ansible playbook
            if data.as_array().is_some() && looks_like_ansible(data) {
                return YamlType::Ansible;
            }
            return YamlType::Generic;
        }
    };

    // 1. Check for explicit $schema field
    if let Some(schema_url) = obj.get("$schema").and_then(|s| s.as_str()) {
        return detect_type_from_schema_url(schema_url);
    }

    // 2. Kubernetes: apiVersion + kind → direct mapping
    if let (Some(_api_version), Some(kind)) = (
        obj.get("apiVersion").and_then(|v| v.as_str()),
        obj.get("kind").and_then(|v| v.as_str()),
    ) {
        return match kind {
            "Deployment" => YamlType::K8sDeployment,
            "Service" => YamlType::K8sService,
            "ConfigMap" => YamlType::K8sConfigMap,
            "Secret" => YamlType::K8sSecret,
            "Ingress" => YamlType::K8sIngress,
            "HorizontalPodAutoscaler" => YamlType::K8sHPA,
            "CronJob" => YamlType::K8sCronJob,
            "Job" => YamlType::K8sJob,
            "PersistentVolumeClaim" => YamlType::K8sPVC,
            "NetworkPolicy" => YamlType::K8sNetworkPolicy,
            "StatefulSet" => YamlType::K8sStatefulSet,
            "DaemonSet" => YamlType::K8sDaemonSet,
            "Role" => YamlType::K8sRole,
            "ClusterRole" => YamlType::K8sClusterRole,
            "RoleBinding" => YamlType::K8sRoleBinding,
            "ClusterRoleBinding" => YamlType::K8sClusterRoleBinding,
            "ServiceAccount" => YamlType::K8sServiceAccount,
            _ => YamlType::K8sGeneric,
        };
    }

    // 3. GitLab CI: has "stages" or jobs with "script"
    if obj.contains_key("stages") || obj.values().any(|v| v.get("script").is_some()) {
        return YamlType::GitLabCI;
    }

    // 4. GitHub Actions: has "on" + "jobs"
    if obj.contains_key("on") && obj.contains_key("jobs") {
        return YamlType::GitHubActions;
    }

    // 5. Docker Compose: has "services" with sub-objects
    if let Some(services) = obj.get("services").and_then(|s| s.as_object())
        && !services.is_empty()
        && services.values().any(|v| v.is_object())
    {
        return YamlType::DockerCompose;
    }

    // 6. Prometheus: has "scrape_configs" or "global.scrape_interval"
    if obj.contains_key("scrape_configs")
        || obj
            .get("global")
            .and_then(|g| g.get("scrape_interval"))
            .is_some()
    {
        return YamlType::Prometheus;
    }

    // 7. Alertmanager: has "route" + "receivers"
    if obj.contains_key("route") && obj.contains_key("receivers") {
        return YamlType::Alertmanager;
    }

    // 8. Helm values.yaml: heuristic detection
    if looks_like_helm_values(obj) {
        return YamlType::HelmValues;
    }

    // 9. OpenAPI: has "openapi" or "swagger" key
    if obj.contains_key("openapi") || obj.contains_key("swagger") {
        return YamlType::OpenAPI;
    }

    YamlType::Generic
}

/// Detect type from $schema URL
fn detect_type_from_schema_url(url: &str) -> YamlType {
    if url.contains("kubernetesjsonschema.dev") || url.contains("kubernetes") {
        YamlType::K8sGeneric
    } else if url.contains("gitlab-ci") {
        YamlType::GitLabCI
    } else if url.contains("github-workflow") || url.contains("github-actions") {
        YamlType::GitHubActions
    } else if url.contains("docker-compose") {
        YamlType::DockerCompose
    } else if url.contains("prometheus") {
        YamlType::Prometheus
    } else if url.contains("alertmanager") {
        YamlType::Alertmanager
    } else if url.contains("openapi") || url.contains("swagger") {
        YamlType::OpenAPI
    } else {
        YamlType::Generic
    }
}

/// Check if top-level array looks like Ansible playbook
fn looks_like_ansible(data: &Value) -> bool {
    data.as_array()
        .map(|arr| {
            arr.iter().all(|item| {
                item.get("hosts").is_some()
                    || item.get("tasks").is_some()
                    || item.get("roles").is_some()
            })
        })
        .unwrap_or(false)
}

/// Heuristic check for Helm values.yaml
fn looks_like_helm_values(obj: &serde_json::Map<String, Value>) -> bool {
    // Helm values often have these common patterns
    let has_common_helm_keys = obj.contains_key("image")
        || obj.contains_key("replicaCount")
        || obj.contains_key("service")
        && obj.get("service").and_then(|s| s.get("type")).is_some();

    // But shouldn't have K8s markers
    let no_k8s_markers = !obj.contains_key("apiVersion") && !obj.contains_key("kind");

    has_common_helm_keys && no_k8s_markers
}

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

    #[test]
    fn test_detect_k8s_deployment() {
        let data = json!({
            "apiVersion": "apps/v1",
            "kind": "Deployment",
            "metadata": { "name": "test" },
            "spec": {}
        });
        assert_eq!(detect_type(&data), YamlType::K8sDeployment);
    }

    #[test]
    fn test_detect_k8s_service() {
        let data = json!({
            "apiVersion": "v1",
            "kind": "Service",
            "metadata": { "name": "test" }
        });
        assert_eq!(detect_type(&data), YamlType::K8sService);
    }

    #[test]
    fn test_detect_gitlab_ci() {
        let data = json!({
            "stages": ["build", "test"],
            "build_job": { "script": ["echo hello"] }
        });
        assert_eq!(detect_type(&data), YamlType::GitLabCI);
    }

    #[test]
    fn test_detect_github_actions() {
        let data = json!({
            "on": ["push"],
            "jobs": { "build": {} }
        });
        assert_eq!(detect_type(&data), YamlType::GitHubActions);
    }

    #[test]
    fn test_detect_docker_compose() {
        let data = json!({
            "services": {
                "web": { "image": "nginx" }
            }
        });
        assert_eq!(detect_type(&data), YamlType::DockerCompose);
    }

    #[test]
    fn test_detect_prometheus() {
        let data = json!({
            "global": { "scrape_interval": "15s" },
            "scrape_configs": []
        });
        assert_eq!(detect_type(&data), YamlType::Prometheus);
    }

    #[test]
    fn test_detect_alertmanager() {
        let data = json!({
            "route": { "receiver": "default" },
            "receivers": [{ "name": "default" }]
        });
        assert_eq!(detect_type(&data), YamlType::Alertmanager);
    }

    #[test]
    fn test_detect_openapi() {
        let data = json!({
            "openapi": "3.0.0",
            "info": { "title": "API", "version": "1.0" }
        });
        assert_eq!(detect_type(&data), YamlType::OpenAPI);
    }

    #[test]
    fn test_schema_key_conversion() {
        assert_eq!(YamlType::K8sDeployment.to_schema_key(), "k8s/deployment");
        assert_eq!(YamlType::GitLabCI.to_schema_key(), "gitlab-ci");
        assert_eq!(YamlType::DockerCompose.to_schema_key(), "docker-compose");
    }
}