1use crate::{Condition, EnvironmentVariables, Error, Id, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
6pub struct ContainerOptions {
7 pub name: String,
8 pub volumes: Option<Vec<String>>,
9 #[serde(rename = "security-opts")]
10 pub security_opts: Option<Vec<String>>,
11}
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14#[serde(untagged)]
15pub enum Container {
16 Options(ContainerOptions),
17 Name(String),
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize, Default)]
21pub struct UserCommandStep {
22 pub name: Option<String>,
23 pub container: Option<Container>,
24 pub run: String,
25 pub on: Option<Condition>,
26 #[serde(rename = "continue-on-error")]
27 pub continue_on_error: Option<bool>,
28 pub environments: Option<EnvironmentVariables>,
29 pub secrets: Option<Vec<String>>,
30 pub timeout: Option<String>,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize, Default)]
34pub struct UserActionStep {
35 pub name: Option<String>,
36 pub uses: String,
37 pub with: Option<serde_yaml::Value>,
38 pub on: Option<Condition>,
39 #[serde(rename = "continue-on-error")]
40 pub continue_on_error: Option<bool>,
41 pub environments: Option<EnvironmentVariables>,
42 pub secrets: Option<Vec<String>>,
43 pub timeout: Option<String>,
44}
45
46#[allow(clippy::large_enum_variant)]
47#[derive(Debug, Clone, Deserialize, Serialize)]
48#[serde(untagged)]
49pub enum UserStep {
50 Command(UserCommandStep),
51 Action(UserActionStep),
52}
53
54#[derive(Serialize, Deserialize, Debug, Clone)]
55pub struct UserJob {
56 pub name: Option<String>,
57 pub container: Option<Container>,
58 #[serde(rename = "working-directories")]
60 pub working_dirs: Option<Vec<String>>,
61 pub steps: Vec<UserStep>,
62 pub on: Option<Condition>,
63 #[serde(rename = "depends-on")]
64 pub depends_on: Option<Vec<String>>,
65}
66
67#[derive(Serialize, Deserialize, Debug, Clone)]
68pub struct UserWorkflow {
69 pub name: Option<String>,
70 pub on: Option<Condition>,
71 pub jobs: HashMap<Id, UserJob>,
72}
73
74impl UserWorkflow {
75 fn validate(workflow: &UserWorkflow) -> Result<()> {
76 if workflow.jobs.is_empty() {
77 return Err(Error::workflow_config_error(
78 "Workflow must have at least one job",
79 ));
80 }
81
82 let mut is_all_jobs_has_dependencies = true;
83 for (job_name, job) in &workflow.jobs {
85 if let Some(depends_on) = &job.depends_on {
86 if !depends_on.is_empty() {
87 for depend_job_key in depends_on {
88 if !workflow.jobs.contains_key(depend_job_key) {
89 return Err(Error::workflow_config_error(format!(
90 "Job {} depends on job {}, but job {} is not defined",
91 job_name, depend_job_key, depend_job_key
92 )));
93 }
94 }
95 } else {
96 is_all_jobs_has_dependencies = false;
97 }
98 } else {
99 is_all_jobs_has_dependencies = false;
100 }
101
102 if job.steps.is_empty() {
103 return Err(Error::workflow_config_error(format!(
104 "Job `{}` must have at least one step",
105 job_name
106 )));
107 }
108 }
109
110 if is_all_jobs_has_dependencies {
111 return Err(Error::workflow_config_error(
112 "Cannot have all jobs has dependencies",
113 ));
114 }
115
116 Ok(())
117 }
118}
119
120impl Container {
121 pub fn name(&self) -> &str {
122 match self {
123 Self::Options(docker) => &docker.name,
124 Self::Name(name) => name,
125 }
126 }
127
128 pub fn normalize(&self) -> ContainerOptions {
129 match self {
130 Self::Options(docker) => docker.clone(),
131 Self::Name(name) => ContainerOptions {
132 name: name.clone(),
133 security_opts: None,
134 volumes: None,
135 },
136 }
137 }
138}
139
140impl TryFrom<&str> for UserWorkflow {
141 type Error = Error;
142
143 fn try_from(value: &str) -> Result<Self> {
144 let workflow = serde_yaml::from_str(value)
145 .map_err(|e| Error::workflow_config_error(format!("Failed to parse workflow: {}", e)))?;
146
147 Self::validate(&workflow)?;
148
149 Ok(workflow)
150 }
151}
152
153impl TryFrom<String> for UserWorkflow {
154 type Error = Error;
155
156 fn try_from(value: String) -> Result<Self> {
157 Self::try_from(value.as_str())
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::{ConditionConfig, EnvironmentVariable, PullRequestCondition, PushCondition};
165
166 #[test]
167 fn test_parse() {
168 let yaml = r#"
169name: Test Workflow
170
171jobs:
172 test-job:
173 name: Test Job
174 working-directories:
175 - /home/runner/work
176 steps:
177 - name: Test Step
178 continue-on-error: true
179 timeout: 10m
180 environments:
181 TEST_ENV: test
182 number: 1
183 boolean: true
184 run: echo "Hello World"
185 - name: Action step
186 uses: cache
187"#;
188
189 let workflow = UserWorkflow::try_from(yaml).unwrap();
190
191 assert_eq!(workflow.name, Some("Test Workflow".to_string()));
192
193 let job = workflow.jobs.get("test-job").unwrap();
194
195 assert_eq!(job.name, Some("Test Job".to_string()));
196
197 let step = job.steps.first().unwrap();
198
199 if let UserStep::Command(command_step) = step {
200 let UserCommandStep {
201 name,
202 environments,
203 run,
204 continue_on_error,
205 timeout,
206 ..
207 } = command_step;
208 assert_eq!(name.as_ref().unwrap(), "Test Step");
209 assert_eq!(timeout.as_ref().unwrap(), "10m");
211 assert_eq!(continue_on_error, &Some(true));
212
213 let environments = environments.clone().unwrap();
214 assert_eq!(
215 environments.get("TEST_ENV").unwrap(),
216 &EnvironmentVariable::String("test".to_string())
217 );
218 assert_eq!(
219 environments.get("number").unwrap(),
220 &EnvironmentVariable::Number(1.0)
221 );
222 assert_eq!(
223 environments.get("boolean").unwrap(),
224 &EnvironmentVariable::Boolean(true)
225 );
226
227 assert_eq!(run, "echo \"Hello World\"");
228 } else {
229 panic!("Step should be command step");
230 }
231 }
232
233 #[test]
234 fn test_empty_jobs() {
235 let yaml = r#"jobs:"#;
236
237 let res = UserWorkflow::try_from(yaml);
238
239 assert_eq!(
240 res.unwrap_err(),
241 Error::workflow_config_error("Workflow must have at least one job")
242 );
243 }
244
245 #[test]
246 fn test_job_depend_not_exist() {
247 let yaml = r#"
248jobs:
249 job1:
250 depends-on: [job2]
251 steps:
252 - run: echo "Hello World"
253"#;
254
255 let res = UserWorkflow::try_from(yaml);
256 assert_eq!(
257 res.unwrap_err(),
258 Error::workflow_config_error("Job job1 depends on job job2, but job job2 is not defined")
259 );
260 }
261
262 #[test]
263 fn test_empty_depend() {
264 let yaml = r#"
265 jobs:
266 job1:
267 depends-on: []
268 steps:
269 - run: echo "Hello World"
270 job2:
271 depends-on: [job1]
272 steps:
273 - run: echo "Hello World"
274 "#;
275
276 UserWorkflow::try_from(yaml).unwrap();
277 }
278
279 #[test]
280 fn test_job_dependencies() {
281 let yaml = r#"
282jobs:
283 job1:
284 depends-on: [job2]
285 steps:
286 - run: echo "Hello World"
287 job2:
288 depends-on: [job1]
289 steps:
290 - run: echo "Hello World"
291"#;
292
293 let res = UserWorkflow::try_from(yaml);
294 assert_eq!(
295 res.unwrap_err(),
296 Error::workflow_config_error("Cannot have all jobs has dependencies")
297 );
298 }
299
300 #[test]
301 fn test_empty_steps() {
302 let yaml = r#"
303jobs:
304 job1:
305 name: Test Job
306 steps:
307"#;
308
309 let res = UserWorkflow::try_from(yaml);
310 assert_eq!(
311 res.unwrap_err(),
312 Error::workflow_config_error("Job `job1` must have at least one step")
313 );
314 }
315
316 #[test]
317 fn test_container_name() {
318 let yaml = r#"
319jobs:
320 job1:
321 name: Test Job
322 container: test
323 steps:
324 - run: echo "Hello World"
325"#;
326
327 let workflow = UserWorkflow::try_from(yaml).unwrap();
328 let job = workflow.jobs.get("job1").unwrap();
329 let container = job.container.as_ref().unwrap();
330 assert_eq!(container.name(), "test");
331 }
332
333 #[test]
334 fn test_container_options() {
335 let yaml = r#"
336jobs:
337 job1:
338 name: Test Job
339 container:
340 name: test
341 volumes:
342 - /home/runner/work
343 security-opts:
344 - seccomp=unconfined
345 steps:
346 - run: echo "Hello World"
347"#;
348
349 let workflow = UserWorkflow::try_from(yaml).unwrap();
350 let job = workflow.jobs.get("job1").unwrap();
351 let container = job.container.as_ref().unwrap();
352 assert_eq!(container.name(), "test");
353
354 let normalized = container.normalize();
355 assert_eq!(normalized.name, "test");
356 assert_eq!(
357 normalized.security_opts,
358 Some(vec!["seccomp=unconfined".to_string()])
359 );
360 assert_eq!(
361 normalized.volumes,
362 Some(vec!["/home/runner/work".to_string()])
363 );
364 }
365
366 #[test]
367 fn test_events_condition() {
368 let yaml = r#"
369on:
370 - push
371 - pull_request
372jobs:
373 job:
374 name: Test Job
375 on:
376 - push
377 - pull_request
378 steps:
379 - run: echo "Hello World"
380 on:
381 - push
382 - pull_request
383"#;
384
385 let workflow = UserWorkflow::try_from(yaml).unwrap();
386 let on = Some(Condition::Event(vec![
387 "push".to_string(),
388 "pull_request".to_string(),
389 ]));
390
391 assert_eq!(&workflow.on, &on);
392
393 let job = workflow.jobs.get("job").unwrap();
394 assert_eq!(&job.on, &on);
395
396 let step = job.steps.first().unwrap();
397
398 if let UserStep::Command(command_step) = step {
399 assert_eq!(&command_step.on, &on);
400 } else {
401 panic!("Step should be command step");
402 }
403 }
404
405 #[test]
406 fn test_config_condition() {
407 let yaml = r#"
408on:
409 push:
410 branches:
411 - master
412 paths:
413 - "src/**"
414jobs:
415 job:
416 name: Test Job
417 on:
418 push:
419 paths:
420 - "src/**"
421 steps:
422 - run: echo "Hello World"
423 on:
424 pull_request:
425 branches:
426 - master
427"#;
428
429 let workflow = UserWorkflow::try_from(yaml).unwrap();
430 let on = Some(Condition::Config(ConditionConfig {
431 push: Some(PushCondition {
432 branches: Some(vec!["master".to_string()]),
433 paths: Some(vec!["src/**".to_string()]),
434 }),
435 pull_request: None,
436 }));
437
438 assert_eq!(workflow.on, on);
439
440 let on = Some(Condition::Config(ConditionConfig {
441 push: Some(PushCondition {
442 branches: None,
443 paths: Some(vec!["src/**".to_string()]),
444 }),
445 pull_request: None,
446 }));
447 let job = workflow.jobs.get("job").unwrap();
448 assert_eq!(job.on, on);
449
450 let step = job.steps.first().unwrap();
451
452 if let UserStep::Command(command_step) = step {
453 assert_eq!(
454 command_step.on,
455 Some(Condition::Config(ConditionConfig {
456 push: None,
457 pull_request: Some(PullRequestCondition {
458 branches: Some(vec!["master".to_string()]),
459 paths: None,
460 }),
461 }))
462 );
463 } else {
464 panic!("Step should be command step");
465 }
466 }
467}