Skip to main content

devops_models/models/
gitlab.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct GitLabArtifacts {
6    #[serde(default)]
7    pub paths: Vec<String>,
8    #[serde(default)]
9    pub expire_in: Option<String>,
10    #[serde(default)]
11    pub when: Option<String>,
12    #[serde(default)]
13    pub reports: Option<serde_json::Value>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GitLabCache {
18    #[serde(default)]
19    pub key: Option<String>,
20    #[serde(default)]
21    pub paths: Vec<String>,
22    #[serde(default)]
23    pub policy: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct GitLabRule {
28    #[serde(default, rename = "if")]
29    pub condition: Option<String>,
30    #[serde(default)]
31    pub when: Option<String>,
32    #[serde(default)]
33    pub changes: Option<Vec<String>>,
34    #[serde(default)]
35    pub exists: Option<Vec<String>>,
36    #[serde(default)]
37    pub allow_failure: Option<bool>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct GitLabJob {
42    #[serde(default)]
43    pub stage: Option<String>,
44    #[serde(default)]
45    pub image: Option<String>,
46    #[serde(default)]
47    pub script: Vec<String>,
48    #[serde(default)]
49    pub before_script: Vec<String>,
50    #[serde(default)]
51    pub after_script: Vec<String>,
52    #[serde(default)]
53    pub artifacts: Option<GitLabArtifacts>,
54    #[serde(default)]
55    pub cache: Option<GitLabCache>,
56    #[serde(default)]
57    pub rules: Vec<GitLabRule>,
58    #[serde(default)]
59    pub needs: Vec<String>,
60    #[serde(default)]
61    pub tags: Vec<String>,
62    #[serde(default)]
63    pub variables: HashMap<String, String>,
64    #[serde(default)]
65    pub allow_failure: Option<bool>,
66    #[serde(default)]
67    pub retry: Option<serde_json::Value>,
68    #[serde(default)]
69    pub timeout: Option<String>,
70    #[serde(default)]
71    pub services: Vec<serde_json::Value>,
72    #[serde(default)]
73    pub only: Option<serde_json::Value>,
74    #[serde(default)]
75    pub except: Option<serde_json::Value>,
76    #[serde(default)]
77    pub environment: Option<serde_json::Value>,
78    #[serde(default)]
79    pub coverage: Option<String>,
80    #[serde(default)]
81    pub when: Option<String>,
82    #[serde(default)]
83    pub extends: Option<serde_json::Value>,
84    #[serde(default)]
85    pub dependencies: Option<Vec<String>>,
86}
87
88/// Top-level GitLab CI configuration.
89/// Reserved keywords are extracted separately; remaining keys are job names.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct GitLabCI {
92    #[serde(default)]
93    pub stages: Vec<String>,
94    #[serde(default)]
95    pub image: Option<String>,
96    #[serde(default)]
97    pub variables: HashMap<String, serde_json::Value>,
98    #[serde(default)]
99    pub default: Option<serde_json::Value>,
100    #[serde(default)]
101    pub include: Option<serde_json::Value>,
102    #[serde(default)]
103    pub workflow: Option<serde_json::Value>,
104    /// Jobs are stored separately since they can have arbitrary names
105    #[serde(skip)]
106    pub jobs: HashMap<String, GitLabJob>,
107}
108
109const RESERVED_KEYWORDS: &[&str] = &[
110    "stages", "image", "variables", "default", "include", "workflow",
111    "before_script", "after_script", "cache", "services",
112];
113
114impl GitLabCI {
115    /// Parse a GitLab CI file from a serde_json::Value, extracting reserved keywords
116    /// and treating remaining top-level keys as job definitions.
117    pub fn from_value(value: &serde_json::Value) -> Result<Self, String> {
118        let obj = value.as_object().ok_or("GitLab CI must be a YAML mapping")?;
119
120        let mut ci: GitLabCI = serde_json::from_value(value.clone())
121            .map_err(|e| format!("Failed to parse GitLab CI config: {e}"))?;
122
123        // Extract jobs (non-reserved top-level keys)
124        for (key, val) in obj {
125            if !RESERVED_KEYWORDS.contains(&key.as_str()) && !key.starts_with('.') {
126                match serde_json::from_value::<GitLabJob>(val.clone()) {
127                    Ok(job) => { ci.jobs.insert(key.clone(), job); }
128                    Err(e) => return Err(format!("Invalid job '{key}': {e}")),
129                }
130            }
131        }
132
133        Ok(ci)
134    }
135
136    /// Validate the GitLab CI configuration and return warnings/errors
137    pub fn validate(&self) -> Vec<String> {
138        let mut warnings = Vec::new();
139
140        // Check that job stages reference declared stages
141        for (name, job) in &self.jobs {
142            if let Some(stage) = &job.stage
143                && !self.stages.is_empty()
144                && !self.stages.contains(stage)
145            {
146                warnings.push(format!(
147                    "Job '{}': stage '{}' not declared in stages list",
148                    name, stage
149                ));
150            }
151
152            // Check that needs reference existing jobs
153            for need in &job.needs {
154                if !self.jobs.contains_key(need) {
155                    warnings.push(format!(
156                        "Job '{}': needs '{}' which is not defined",
157                        name, need
158                    ));
159                }
160            }
161
162            // Check script is not empty
163            if job.script.is_empty() {
164                warnings.push(format!("Job '{}': script is empty", name));
165            }
166        }
167
168        if self.stages.is_empty() && !self.jobs.is_empty() {
169            warnings.push("No 'stages' defined — jobs may run in undefined order".to_string());
170        }
171
172        warnings
173    }
174}