wrkflw_parser/
workflow.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5use wrkflw_matrix::MatrixConfig;
6
7use super::schema::SchemaValidator;
8
9// Custom deserializer for needs field that handles both string and array formats
10fn deserialize_needs<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
11where
12    D: Deserializer<'de>,
13{
14    #[derive(Deserialize)]
15    #[serde(untagged)]
16    enum StringOrVec {
17        String(String),
18        Vec(Vec<String>),
19    }
20
21    let value = Option::<StringOrVec>::deserialize(deserializer)?;
22    match value {
23        Some(StringOrVec::String(s)) => Ok(Some(vec![s])),
24        Some(StringOrVec::Vec(v)) => Ok(Some(v)),
25        None => Ok(None),
26    }
27}
28
29// Custom deserializer for runs-on field that handles both string and array formats
30fn deserialize_runs_on<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
31where
32    D: Deserializer<'de>,
33{
34    #[derive(Deserialize)]
35    #[serde(untagged)]
36    enum StringOrVec {
37        String(String),
38        Vec(Vec<String>),
39    }
40
41    let value = Option::<StringOrVec>::deserialize(deserializer)?;
42    match value {
43        Some(StringOrVec::String(s)) => Ok(Some(vec![s])),
44        Some(StringOrVec::Vec(v)) => Ok(Some(v)),
45        None => Ok(None),
46    }
47}
48
49#[derive(Debug, Deserialize, Serialize)]
50pub struct WorkflowDefinition {
51    pub name: String,
52    #[serde(skip, default)] // Skip deserialization of the 'on' field directly
53    pub on: Vec<String>,
54    #[serde(rename = "on")] // Raw access to the 'on' field for custom handling
55    pub on_raw: serde_yaml::Value,
56    pub jobs: HashMap<String, Job>,
57}
58
59#[derive(Debug, Deserialize, Serialize)]
60pub struct Job {
61    #[serde(rename = "runs-on", default, deserialize_with = "deserialize_runs_on")]
62    pub runs_on: Option<Vec<String>>,
63    #[serde(default, deserialize_with = "deserialize_needs")]
64    pub needs: Option<Vec<String>>,
65    #[serde(default)]
66    pub steps: Vec<Step>,
67    #[serde(default)]
68    pub env: HashMap<String, String>,
69    #[serde(default)]
70    pub matrix: Option<MatrixConfig>,
71    #[serde(default)]
72    pub services: HashMap<String, Service>,
73    #[serde(default, rename = "if")]
74    pub if_condition: Option<String>,
75    #[serde(default)]
76    pub outputs: Option<HashMap<String, String>>,
77    #[serde(default)]
78    pub permissions: Option<HashMap<String, String>>,
79    // Reusable workflow (job-level 'uses') support
80    #[serde(default)]
81    pub uses: Option<String>,
82    #[serde(default)]
83    pub with: Option<HashMap<String, String>>,
84    #[serde(default)]
85    pub secrets: Option<serde_yaml::Value>,
86}
87
88#[derive(Debug, Deserialize, Serialize)]
89pub struct Service {
90    pub image: String,
91    #[serde(default)]
92    pub ports: Option<Vec<String>>,
93    #[serde(default)]
94    pub env: HashMap<String, String>,
95    #[serde(default)]
96    pub volumes: Option<Vec<String>>,
97    #[serde(default)]
98    pub options: Option<String>,
99}
100
101#[derive(Debug, Deserialize, Serialize)]
102pub struct Step {
103    #[serde(default)]
104    pub name: Option<String>,
105    #[serde(default)]
106    pub uses: Option<String>,
107    #[serde(default)]
108    pub run: Option<String>,
109    #[serde(default)]
110    pub with: Option<HashMap<String, String>>,
111    #[serde(default)]
112    pub env: HashMap<String, String>,
113    #[serde(default)]
114    pub continue_on_error: Option<bool>,
115}
116
117impl WorkflowDefinition {
118    pub fn resolve_action(&self, action_ref: &str) -> ActionInfo {
119        // Parse GitHub action reference like "actions/checkout@v3"
120        let parts: Vec<&str> = action_ref.split('@').collect();
121
122        let (repo, _) = if parts.len() > 1 {
123            (parts[0], parts[1])
124        } else {
125            (parts[0], "main") // Default to main if no version specified
126        };
127
128        ActionInfo {
129            repository: repo.to_string(),
130            is_docker: repo.starts_with("docker://"),
131            is_local: repo.starts_with("./"),
132        }
133    }
134}
135
136#[derive(Debug, Clone)]
137pub struct ActionInfo {
138    pub repository: String,
139    pub is_docker: bool,
140    pub is_local: bool,
141}
142
143pub fn parse_workflow(path: &Path) -> Result<WorkflowDefinition, String> {
144    // First validate against schema
145    let validator = SchemaValidator::new()?;
146    validator.validate_workflow(path)?;
147
148    // If validation passes, parse the workflow
149    let content =
150        fs::read_to_string(path).map_err(|e| format!("Failed to read workflow file: {}", e))?;
151
152    // Parse the YAML content
153    let mut workflow: WorkflowDefinition = serde_yaml::from_str(&content)
154        .map_err(|e| format!("Failed to parse workflow structure: {}", e))?;
155
156    // Normalize the trigger events
157    workflow.on = normalize_triggers(&workflow.on_raw)?;
158
159    Ok(workflow)
160}
161
162fn normalize_triggers(on_value: &serde_yaml::Value) -> Result<Vec<String>, String> {
163    let mut triggers = Vec::new();
164
165    match on_value {
166        // Simple string trigger: on: push
167        serde_yaml::Value::String(event) => {
168            triggers.push(event.clone());
169        }
170        // Array of triggers: on: [push, pull_request]
171        serde_yaml::Value::Sequence(events) => {
172            for event in events {
173                if let Some(event_str) = event.as_str() {
174                    triggers.push(event_str.to_string());
175                }
176            }
177        }
178        // Map of triggers with configuration: on: {push: {branches: [main]}}
179        serde_yaml::Value::Mapping(events_map) => {
180            for (event, _) in events_map {
181                if let Some(event_str) = event.as_str() {
182                    triggers.push(event_str.to_string());
183                }
184            }
185        }
186        _ => {
187            return Err("'on' section has invalid format".to_string());
188        }
189    }
190
191    Ok(triggers)
192}