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>>,
}
#[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>,
#[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 {
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}"))?;
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)
}
pub fn validate(&self) -> Vec<String> {
let mut warnings = Vec::new();
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
));
}
for need in &job.needs {
if !self.jobs.contains_key(need) {
warnings.push(format!(
"Job '{}': needs '{}' which is not defined",
name, need
));
}
}
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
}
}