rust_actions/
parser.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use crate::Result;
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct Workflow {
9    pub name: String,
10    #[serde(default)]
11    pub on: Option<WorkflowTrigger>,
12    #[serde(default)]
13    pub env: HashMap<String, String>,
14    #[serde(default)]
15    pub jobs: HashMap<String, Job>,
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
19pub struct WorkflowTrigger {
20    #[serde(default)]
21    pub workflow_call: Option<WorkflowCallConfig>,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub struct WorkflowCallConfig {
26    #[serde(default)]
27    pub inputs: HashMap<String, InputDef>,
28    #[serde(default)]
29    pub outputs: HashMap<String, OutputDef>,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
33pub struct InputDef {
34    #[serde(default)]
35    pub description: Option<String>,
36    #[serde(default)]
37    pub required: bool,
38    #[serde(default)]
39    pub default: Option<serde_json::Value>,
40    #[serde(rename = "type", default)]
41    pub input_type: Option<String>,
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct OutputDef {
46    #[serde(default)]
47    pub description: Option<String>,
48    pub value: String,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct Job {
53    #[serde(default)]
54    pub name: Option<String>,
55    #[serde(default)]
56    pub needs: JobNeeds,
57    #[serde(default)]
58    pub uses: Option<String>,
59    #[serde(default)]
60    pub with: HashMap<String, serde_json::Value>,
61    #[serde(default)]
62    pub strategy: Option<Strategy>,
63    #[serde(default)]
64    pub outputs: HashMap<String, String>,
65    #[serde(default)]
66    pub env: HashMap<String, String>,
67    #[serde(default)]
68    pub steps: Vec<Step>,
69}
70
71#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72#[serde(untagged)]
73pub enum JobNeeds {
74    #[default]
75    None,
76    Single(String),
77    Multiple(Vec<String>),
78}
79
80impl JobNeeds {
81    pub fn as_vec(&self) -> Vec<String> {
82        match self {
83            JobNeeds::None => vec![],
84            JobNeeds::Single(s) => vec![s.clone()],
85            JobNeeds::Multiple(v) => v.clone(),
86        }
87    }
88
89    pub fn is_empty(&self) -> bool {
90        match self {
91            JobNeeds::None => true,
92            JobNeeds::Single(_) => false,
93            JobNeeds::Multiple(v) => v.is_empty(),
94        }
95    }
96}
97
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct Strategy {
100    #[serde(default)]
101    pub matrix: Matrix,
102    #[serde(default = "default_true", rename = "fail-fast")]
103    pub fail_fast: bool,
104    #[serde(default, rename = "max-parallel")]
105    pub max_parallel: Option<usize>,
106}
107
108#[derive(Debug, Clone, Default, Deserialize, Serialize)]
109pub struct Matrix {
110    #[serde(default)]
111    pub include: Vec<HashMap<String, serde_json::Value>>,
112    #[serde(default)]
113    pub exclude: Vec<HashMap<String, serde_json::Value>>,
114    #[serde(flatten)]
115    pub dimensions: HashMap<String, Vec<serde_json::Value>>,
116}
117
118fn default_true() -> bool {
119    true
120}
121
122#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct Step {
124    #[serde(default)]
125    pub name: Option<String>,
126    #[serde(default)]
127    pub id: Option<String>,
128    pub uses: String,
129    #[serde(default)]
130    pub with: HashMap<String, serde_json::Value>,
131    #[serde(default, rename = "continue-on-error")]
132    pub continue_on_error: bool,
133    #[serde(default, rename = "pre-assert")]
134    pub pre_assert: Vec<String>,
135    #[serde(default, rename = "post-assert")]
136    pub post_assert: Vec<String>,
137}
138
139impl Workflow {
140    pub fn from_yaml(yaml: &str) -> Result<Self> {
141        let workflow: Workflow = serde_yaml::from_str(yaml)?;
142        Ok(workflow)
143    }
144
145    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
146        let content = std::fs::read_to_string(path)?;
147        Self::from_yaml(&content)
148    }
149
150    pub fn is_reusable(&self) -> bool {
151        self.on
152            .as_ref()
153            .map(|t| t.workflow_call.is_some())
154            .unwrap_or(false)
155    }
156}
157
158pub fn parse_workflows(path: impl AsRef<Path>) -> Result<Vec<(PathBuf, Workflow)>> {
159    let path = path.as_ref();
160    let mut workflows = Vec::new();
161
162    if path.is_file() {
163        workflows.push((path.to_path_buf(), Workflow::from_file(path)?));
164    } else if path.is_dir() {
165        parse_workflows_recursive(path, path, &mut workflows)?;
166    }
167
168    Ok(workflows)
169}
170
171fn parse_workflows_recursive(
172    base_path: &Path,
173    current_path: &Path,
174    workflows: &mut Vec<(PathBuf, Workflow)>,
175) -> Result<()> {
176    for entry in std::fs::read_dir(current_path)? {
177        let entry = entry?;
178        let path = entry.path();
179
180        if path.is_dir() {
181            parse_workflows_recursive(base_path, &path, workflows)?;
182        } else if path.is_file() {
183            let ext = path.extension().and_then(|e| e.to_str());
184            if matches!(ext, Some("yaml") | Some("yml")) {
185                let rel_path = path
186                    .strip_prefix(base_path)
187                    .unwrap_or(&path)
188                    .to_path_buf();
189                workflows.push((rel_path, Workflow::from_file(&path)?));
190            }
191        }
192    }
193    Ok(())
194}
195
196pub fn parse_workflow_file(path: impl AsRef<Path>) -> Result<(PathBuf, Workflow)> {
197    let path = path.as_ref();
198    Ok((path.to_path_buf(), Workflow::from_file(path)?))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_parse_reusable_workflow() {
207        let yaml = r#"
208name: User Setup
209on:
210  workflow_call:
211    outputs:
212      user_id:
213        value: ${{ jobs.setup.outputs.user_id }}
214      session_token:
215        value: ${{ jobs.setup.outputs.session_token }}
216
217jobs:
218  setup:
219    outputs:
220      user_id: ${{ steps.user.outputs.id }}
221      session_token: ${{ steps.session.outputs.token }}
222    steps:
223      - uses: user/create
224        id: user
225      - uses: auth/login
226        id: session
227        with:
228          user_id: ${{ steps.user.outputs.id }}
229"#;
230
231        let workflow = Workflow::from_yaml(yaml).unwrap();
232        assert_eq!(workflow.name, "User Setup");
233        assert!(workflow.is_reusable());
234        assert!(workflow.jobs.contains_key("setup"));
235
236        let setup_job = &workflow.jobs["setup"];
237        assert_eq!(setup_job.steps.len(), 2);
238        assert_eq!(setup_job.outputs.len(), 2);
239    }
240
241    #[test]
242    fn test_parse_runnable_workflow() {
243        let yaml = r#"
244name: Order Tests
245
246jobs:
247  setup:
248    uses: "@file:setup/user-setup.yaml"
249
250  place-order:
251    needs: [setup]
252    steps:
253      - uses: order/create
254        with:
255          token: ${{ needs.setup.outputs.session_token }}
256        post-assert:
257          - ${{ outputs.order_id != "" }}
258"#;
259
260        let workflow = Workflow::from_yaml(yaml).unwrap();
261        assert_eq!(workflow.name, "Order Tests");
262        assert!(!workflow.is_reusable());
263
264        let setup_job = &workflow.jobs["setup"];
265        assert_eq!(
266            setup_job.uses.as_deref(),
267            Some("@file:setup/user-setup.yaml")
268        );
269
270        let order_job = &workflow.jobs["place-order"];
271        assert_eq!(order_job.needs.as_vec(), vec!["setup"]);
272        assert_eq!(order_job.steps.len(), 1);
273    }
274
275    #[test]
276    fn test_parse_matrix_workflow() {
277        let yaml = r#"
278name: Feature Flag Compatibility
279
280jobs:
281  test-flags:
282    strategy:
283      matrix:
284        service_a_feature_x: [true, false]
285        service_b_feature_y: [true, false]
286      fail-fast: false
287    steps:
288      - uses: service-a/configure
289        with:
290          feature_x: ${{ matrix.service_a_feature_x }}
291      - uses: service-b/configure
292        with:
293          feature_y: ${{ matrix.service_b_feature_y }}
294"#;
295
296        let workflow = Workflow::from_yaml(yaml).unwrap();
297        assert_eq!(workflow.name, "Feature Flag Compatibility");
298
299        let job = &workflow.jobs["test-flags"];
300        let strategy = job.strategy.as_ref().unwrap();
301        assert!(!strategy.fail_fast);
302        assert_eq!(strategy.matrix.dimensions.len(), 2);
303        assert_eq!(strategy.matrix.dimensions["service_a_feature_x"].len(), 2);
304    }
305
306    #[test]
307    fn test_parse_matrix_with_include_exclude() {
308        let yaml = r#"
309name: Matrix Test
310
311jobs:
312  test:
313    strategy:
314      matrix:
315        service_a: [v1, v2]
316        service_b: [v1, v2]
317        include:
318          - service_a: v3-beta
319            service_b: v2
320            experimental: true
321        exclude:
322          - service_a: v1
323            service_b: v2
324    steps:
325      - uses: test/run
326"#;
327
328        let workflow = Workflow::from_yaml(yaml).unwrap();
329        let job = &workflow.jobs["test"];
330        let strategy = job.strategy.as_ref().unwrap();
331
332        assert_eq!(strategy.matrix.include.len(), 1);
333        assert_eq!(strategy.matrix.exclude.len(), 1);
334        assert_eq!(
335            strategy.matrix.include[0]["experimental"],
336            serde_json::Value::Bool(true)
337        );
338    }
339}