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}