wrkflw_parser/
gitlab.rs

1use crate::schema::{SchemaType, SchemaValidator};
2use crate::workflow;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6use thiserror::Error;
7use wrkflw_models::gitlab::Pipeline;
8use wrkflw_models::ValidationResult;
9
10#[derive(Error, Debug)]
11pub enum GitlabParserError {
12    #[error("I/O error: {0}")]
13    IoError(#[from] std::io::Error),
14
15    #[error("YAML parsing error: {0}")]
16    YamlError(#[from] serde_yaml::Error),
17
18    #[error("Invalid pipeline structure: {0}")]
19    InvalidStructure(String),
20
21    #[error("Schema validation error: {0}")]
22    SchemaValidationError(String),
23}
24
25/// Parse a GitLab CI/CD pipeline file
26pub fn parse_pipeline(pipeline_path: &Path) -> Result<Pipeline, GitlabParserError> {
27    // Read the pipeline file
28    let pipeline_content = fs::read_to_string(pipeline_path)?;
29
30    // Validate against schema
31    let validator = SchemaValidator::new().map_err(GitlabParserError::SchemaValidationError)?;
32
33    validator
34        .validate_with_specific_schema(&pipeline_content, SchemaType::GitLab)
35        .map_err(GitlabParserError::SchemaValidationError)?;
36
37    // Parse the pipeline YAML
38    let pipeline: Pipeline = serde_yaml::from_str(&pipeline_content)?;
39
40    // Return the parsed pipeline
41    Ok(pipeline)
42}
43
44/// Validate the basic structure of a GitLab CI/CD pipeline
45pub fn validate_pipeline_structure(pipeline: &Pipeline) -> ValidationResult {
46    let mut result = ValidationResult::new();
47
48    // Check for at least one job
49    if pipeline.jobs.is_empty() {
50        result.add_issue("Pipeline must contain at least one job".to_string());
51    }
52
53    // Check for script in jobs
54    for (job_name, job) in &pipeline.jobs {
55        // Skip template jobs
56        if let Some(true) = job.template {
57            continue;
58        }
59
60        // Check for script or extends
61        if job.script.is_none() && job.extends.is_none() {
62            result.add_issue(format!(
63                "Job '{}' must have a script section or extend another job",
64                job_name
65            ));
66        }
67    }
68
69    // Check that referenced stages are defined
70    if let Some(stages) = &pipeline.stages {
71        for (job_name, job) in &pipeline.jobs {
72            if let Some(stage) = &job.stage {
73                if !stages.contains(stage) {
74                    result.add_issue(format!(
75                        "Job '{}' references undefined stage '{}'",
76                        job_name, stage
77                    ));
78                }
79            }
80        }
81    }
82
83    // Check that job dependencies exist
84    for (job_name, job) in &pipeline.jobs {
85        if let Some(dependencies) = &job.dependencies {
86            for dependency in dependencies {
87                if !pipeline.jobs.contains_key(dependency) {
88                    result.add_issue(format!(
89                        "Job '{}' depends on undefined job '{}'",
90                        job_name, dependency
91                    ));
92                }
93            }
94        }
95    }
96
97    // Check that job extensions exist
98    for (job_name, job) in &pipeline.jobs {
99        if let Some(extends) = &job.extends {
100            for extend in extends {
101                if !pipeline.jobs.contains_key(extend) {
102                    result.add_issue(format!(
103                        "Job '{}' extends undefined job '{}'",
104                        job_name, extend
105                    ));
106                }
107            }
108        }
109    }
110
111    result
112}
113
114/// Convert a GitLab CI/CD pipeline to a format compatible with the workflow executor
115pub fn convert_to_workflow_format(pipeline: &Pipeline) -> workflow::WorkflowDefinition {
116    // Create a new workflow with required fields
117    let mut workflow = workflow::WorkflowDefinition {
118        name: "Converted GitLab CI Pipeline".to_string(),
119        on: vec!["push".to_string()], // Default trigger
120        on_raw: serde_yaml::Value::String("push".to_string()),
121        jobs: HashMap::new(),
122    };
123
124    // Convert each GitLab job to a GitHub Actions job
125    for (job_name, gitlab_job) in &pipeline.jobs {
126        // Skip template jobs
127        if let Some(true) = gitlab_job.template {
128            continue;
129        }
130
131        // Create a new job
132        let mut job = workflow::Job {
133            runs_on: Some(vec!["ubuntu-latest".to_string()]), // Default runner
134            needs: None,
135            steps: Vec::new(),
136            env: HashMap::new(),
137            matrix: None,
138            services: HashMap::new(),
139            if_condition: None,
140            outputs: None,
141            permissions: None,
142            uses: None,
143            with: None,
144            secrets: None,
145        };
146
147        // Add job-specific environment variables
148        if let Some(variables) = &gitlab_job.variables {
149            job.env.extend(variables.clone());
150        }
151
152        // Add global variables if they exist
153        if let Some(variables) = &pipeline.variables {
154            // Only add if not already defined at job level
155            for (key, value) in variables {
156                job.env.entry(key.clone()).or_insert_with(|| value.clone());
157            }
158        }
159
160        // Convert before_script to steps if it exists
161        if let Some(before_script) = &gitlab_job.before_script {
162            for (i, cmd) in before_script.iter().enumerate() {
163                let step = workflow::Step {
164                    name: Some(format!("Before script {}", i + 1)),
165                    uses: None,
166                    run: Some(cmd.clone()),
167                    with: None,
168                    env: HashMap::new(),
169                    continue_on_error: None,
170                };
171                job.steps.push(step);
172            }
173        }
174
175        // Convert main script to steps
176        if let Some(script) = &gitlab_job.script {
177            for (i, cmd) in script.iter().enumerate() {
178                let step = workflow::Step {
179                    name: Some(format!("Run script line {}", i + 1)),
180                    uses: None,
181                    run: Some(cmd.clone()),
182                    with: None,
183                    env: HashMap::new(),
184                    continue_on_error: None,
185                };
186                job.steps.push(step);
187            }
188        }
189
190        // Convert after_script to steps if it exists
191        if let Some(after_script) = &gitlab_job.after_script {
192            for (i, cmd) in after_script.iter().enumerate() {
193                let step = workflow::Step {
194                    name: Some(format!("After script {}", i + 1)),
195                    uses: None,
196                    run: Some(cmd.clone()),
197                    with: None,
198                    env: HashMap::new(),
199                    continue_on_error: Some(true), // After script should continue even if previous steps fail
200                };
201                job.steps.push(step);
202            }
203        }
204
205        // Add services if they exist
206        if let Some(services) = &gitlab_job.services {
207            for (i, service) in services.iter().enumerate() {
208                let service_name = format!("service-{}", i);
209                let service_image = match service {
210                    wrkflw_models::gitlab::Service::Simple(name) => name.clone(),
211                    wrkflw_models::gitlab::Service::Detailed { name, .. } => name.clone(),
212                };
213
214                let service = workflow::Service {
215                    image: service_image,
216                    ports: None,
217                    env: HashMap::new(),
218                    volumes: None,
219                    options: None,
220                };
221
222                job.services.insert(service_name, service);
223            }
224        }
225
226        // Add the job to the workflow
227        workflow.jobs.insert(job_name.clone(), job);
228    }
229
230    workflow
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    // use std::path::PathBuf; // unused
237    use tempfile::NamedTempFile;
238
239    #[test]
240    fn test_parse_simple_pipeline() {
241        // Create a temporary file with a simple GitLab CI/CD pipeline
242        let file = NamedTempFile::new().unwrap();
243        let content = r#"
244stages:
245  - build
246  - test
247
248build_job:
249  stage: build
250  script:
251    - echo "Building..."
252    - make build
253
254test_job:
255  stage: test
256  script:
257    - echo "Testing..."
258    - make test
259"#;
260        fs::write(&file, content).unwrap();
261
262        // Parse the pipeline
263        let pipeline = parse_pipeline(file.path()).unwrap();
264
265        // Validate basic structure
266        assert_eq!(pipeline.stages.as_ref().unwrap().len(), 2);
267        assert_eq!(pipeline.jobs.len(), 2);
268
269        // Check job contents
270        let build_job = pipeline.jobs.get("build_job").unwrap();
271        assert_eq!(build_job.stage.as_ref().unwrap(), "build");
272        assert_eq!(build_job.script.as_ref().unwrap().len(), 2);
273
274        let test_job = pipeline.jobs.get("test_job").unwrap();
275        assert_eq!(test_job.stage.as_ref().unwrap(), "test");
276        assert_eq!(test_job.script.as_ref().unwrap().len(), 2);
277    }
278}