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
9fn 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
29fn 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
49fn deserialize_container<'de, D>(deserializer: D) -> Result<Option<JobContainer>, D::Error>
51where
52 D: Deserializer<'de>,
53{
54 #[derive(Deserialize)]
55 #[serde(untagged)]
56 enum StringOrContainer {
57 String(String),
58 Container(JobContainer),
59 }
60
61 let value = Option::<StringOrContainer>::deserialize(deserializer)?;
62 match value {
63 Some(StringOrContainer::String(image)) => Ok(Some(JobContainer {
64 image,
65 credentials: None,
66 env: HashMap::new(),
67 ports: None,
68 volumes: None,
69 options: None,
70 })),
71 Some(StringOrContainer::Container(c)) => Ok(Some(c)),
72 None => Ok(None),
73 }
74}
75
76#[derive(Deserialize, Clone)]
77pub struct ContainerCredentials {
78 #[serde(default)]
79 pub username: Option<String>,
80 #[serde(default)]
81 pub password: Option<String>,
82}
83
84impl serde::Serialize for ContainerCredentials {
85 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
86 where
87 S: serde::Serializer,
88 {
89 use serde::ser::SerializeStruct;
90 let mut state = serializer.serialize_struct("ContainerCredentials", 2)?;
91 state.serialize_field("username", &self.username)?;
92 if self.password.is_some() {
93 state.serialize_field("password", &"[REDACTED]")?;
94 } else {
95 state.serialize_field("password", &None::<String>)?;
96 }
97 state.end()
98 }
99}
100
101impl std::fmt::Debug for ContainerCredentials {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.debug_struct("ContainerCredentials")
104 .field("username", &self.username)
105 .field("password", &"[REDACTED]")
106 .finish()
107 }
108}
109
110#[derive(Debug, Deserialize, Serialize, Clone)]
111pub struct JobContainer {
112 pub image: String,
113 #[serde(default)]
114 pub credentials: Option<ContainerCredentials>,
115 #[serde(default)]
116 pub env: HashMap<String, String>,
117 #[serde(default)]
118 pub ports: Option<Vec<String>>,
119 #[serde(default)]
120 pub volumes: Option<Vec<String>>,
121 #[serde(default)]
122 pub options: Option<String>,
123}
124
125#[derive(Debug, Deserialize, Serialize, Clone, Default)]
126pub struct DefaultsRun {
127 #[serde(default)]
128 pub shell: Option<String>,
129 #[serde(default, rename = "working-directory")]
130 pub working_directory: Option<String>,
131}
132
133#[derive(Debug, Deserialize, Serialize, Clone, Default)]
134pub struct Defaults {
135 #[serde(default)]
136 pub run: Option<DefaultsRun>,
137}
138
139#[derive(Debug, Deserialize, Serialize)]
140pub struct WorkflowDefinition {
141 pub name: String,
142 #[serde(skip, default)] pub on: Vec<String>,
144 #[serde(rename = "on")] pub on_raw: serde_yaml::Value,
146 pub jobs: HashMap<String, Job>,
147 #[serde(default)]
148 pub defaults: Option<Defaults>,
149 #[serde(default)]
150 pub env: HashMap<String, String>,
151}
152
153#[derive(Debug, Deserialize, Serialize, Default)]
154pub struct Strategy {
155 #[serde(default)]
156 pub matrix: Option<MatrixConfig>,
157 #[serde(default, rename = "fail-fast")]
158 pub fail_fast: Option<bool>,
159 #[serde(default, rename = "max-parallel")]
160 pub max_parallel: Option<usize>,
161}
162
163#[derive(Debug, Deserialize, Serialize)]
164pub struct Job {
165 #[serde(rename = "runs-on", default, deserialize_with = "deserialize_runs_on")]
166 pub runs_on: Option<Vec<String>>,
167 #[serde(default, deserialize_with = "deserialize_needs")]
168 pub needs: Option<Vec<String>>,
169 #[serde(default, deserialize_with = "deserialize_container")]
170 pub container: Option<JobContainer>,
171 #[serde(default)]
172 pub steps: Vec<Step>,
173 #[serde(default)]
174 pub env: HashMap<String, String>,
175 #[serde(default, alias = "matrix")]
176 pub strategy: Option<Strategy>,
177 #[serde(default)]
178 pub services: HashMap<String, Service>,
179 #[serde(default, rename = "if")]
180 pub if_condition: Option<String>,
181 #[serde(default)]
182 pub outputs: Option<HashMap<String, String>>,
183 #[serde(default)]
184 pub permissions: Option<HashMap<String, String>>,
185 #[serde(default)]
187 pub uses: Option<String>,
188 #[serde(default)]
189 pub with: Option<HashMap<String, String>>,
190 #[serde(default)]
191 pub secrets: Option<serde_yaml::Value>,
192 #[serde(default, rename = "timeout-minutes")]
193 pub timeout_minutes: Option<f64>,
194 #[serde(default)]
195 pub defaults: Option<Defaults>,
196}
197
198impl Job {
199 pub fn matrix_config(&self) -> Option<&MatrixConfig> {
201 self.strategy.as_ref().and_then(|s| s.matrix.as_ref())
202 }
203
204 pub fn fail_fast(&self) -> bool {
206 self.strategy
207 .as_ref()
208 .and_then(|s| s.fail_fast)
209 .or_else(|| self.matrix_config().and_then(|m| m.fail_fast))
210 .unwrap_or(true)
211 }
212
213 pub fn max_parallel(&self) -> Option<usize> {
215 self.strategy
216 .as_ref()
217 .and_then(|s| s.max_parallel)
218 .or_else(|| self.matrix_config().and_then(|m| m.max_parallel))
219 }
220}
221
222#[derive(Debug, Deserialize, Serialize)]
223pub struct Service {
224 pub image: String,
225 #[serde(default)]
226 pub ports: Option<Vec<String>>,
227 #[serde(default)]
228 pub env: HashMap<String, String>,
229 #[serde(default)]
230 pub volumes: Option<Vec<String>>,
231 #[serde(default)]
232 pub options: Option<String>,
233}
234
235#[derive(Debug, Deserialize, Serialize)]
236pub struct Step {
237 #[serde(default)]
238 pub name: Option<String>,
239 #[serde(default)]
240 pub uses: Option<String>,
241 #[serde(default)]
242 pub run: Option<String>,
243 #[serde(default)]
244 pub with: Option<HashMap<String, String>>,
245 #[serde(default)]
246 pub env: HashMap<String, String>,
247 #[serde(default, rename = "continue-on-error")]
248 pub continue_on_error: Option<bool>,
249 #[serde(default, rename = "if")]
250 pub if_condition: Option<String>,
251 #[serde(default)]
252 pub id: Option<String>,
253 #[serde(default, rename = "working-directory")]
254 pub working_directory: Option<String>,
255 #[serde(default)]
256 pub shell: Option<String>,
257 #[serde(default, rename = "timeout-minutes")]
258 pub timeout_minutes: Option<f64>,
259}
260
261impl Step {
262 pub fn with_run(name: impl Into<String>, run: impl Into<String>) -> Self {
264 Self {
265 name: Some(name.into()),
266 uses: None,
267 run: Some(run.into()),
268 with: None,
269 env: HashMap::new(),
270 continue_on_error: None,
271 if_condition: None,
272 id: None,
273 working_directory: None,
274 shell: None,
275 timeout_minutes: None,
276 }
277 }
278}
279
280impl WorkflowDefinition {
281 pub fn resolve_action(&self, action_ref: &str) -> ActionInfo {
282 let is_docker = action_ref.starts_with("docker://");
284 let is_local = action_ref.starts_with("./");
285
286 if is_docker {
290 return ActionInfo {
291 repository: action_ref.to_string(),
292 version: String::new(),
293 sub_path: None,
294 is_docker: true,
295 is_local: false,
296 };
297 }
298 if is_local {
299 return ActionInfo {
300 repository: action_ref.to_string(),
301 version: String::new(),
302 sub_path: None,
303 is_docker: false,
304 is_local: true,
305 };
306 }
307
308 let (full_repo, version) = if let Some(at_pos) = action_ref.find('@') {
310 (&action_ref[..at_pos], &action_ref[at_pos + 1..])
311 } else {
312 (action_ref, "main") };
314
315 let parts: Vec<&str> = full_repo.splitn(3, '/').collect();
318 let (repo, sub_path) = if parts.len() == 3 {
319 (
320 format!("{}/{}", parts[0], parts[1]),
321 Some(parts[2].to_string()),
322 )
323 } else {
324 (full_repo.to_string(), None)
325 };
326
327 ActionInfo {
328 repository: repo,
329 version: version.to_string(),
330 sub_path,
331 is_docker: false,
332 is_local: false,
333 }
334 }
335}
336
337#[derive(Debug, Clone)]
338pub struct ActionInfo {
339 pub repository: String,
341 pub version: String,
345 pub sub_path: Option<String>,
348 pub is_docker: bool,
349 pub is_local: bool,
350}
351
352pub fn parse_workflow(path: &Path) -> Result<WorkflowDefinition, String> {
353 let validator = SchemaValidator::new()?;
355 validator.validate_workflow(path)?;
356
357 let content =
359 fs::read_to_string(path).map_err(|e| format!("Failed to read workflow file: {}", e))?;
360
361 let mut workflow: WorkflowDefinition = serde_yaml::from_str(&content)
363 .map_err(|e| format!("Failed to parse workflow structure: {}", e))?;
364
365 workflow.on = normalize_triggers(&workflow.on_raw)?;
367
368 Ok(workflow)
369}
370
371fn normalize_triggers(on_value: &serde_yaml::Value) -> Result<Vec<String>, String> {
372 let mut triggers = Vec::new();
373
374 match on_value {
375 serde_yaml::Value::String(event) => {
377 triggers.push(event.clone());
378 }
379 serde_yaml::Value::Sequence(events) => {
381 for event in events {
382 if let Some(event_str) = event.as_str() {
383 triggers.push(event_str.to_string());
384 }
385 }
386 }
387 serde_yaml::Value::Mapping(events_map) => {
389 for (event, _) in events_map {
390 if let Some(event_str) = event.as_str() {
391 triggers.push(event_str.to_string());
392 }
393 }
394 }
395 _ => {
396 return Err("'on' section has invalid format".to_string());
397 }
398 }
399
400 Ok(triggers)
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use std::fs;
407 use tempfile::tempdir;
408
409 #[test]
410 fn resolve_action_parses_version() {
411 let wd = WorkflowDefinition {
412 name: String::new(),
413 on: vec![],
414 on_raw: serde_yaml::Value::Null,
415 jobs: Default::default(),
416 defaults: None,
417 env: HashMap::new(),
418 };
419 let info = wd.resolve_action("actions/checkout@v4");
420 assert_eq!(info.repository, "actions/checkout");
421 assert_eq!(info.version, "v4");
422 assert!(info.sub_path.is_none());
423 assert!(!info.is_docker);
424 assert!(!info.is_local);
425 }
426
427 #[test]
428 fn resolve_action_defaults_version_to_main() {
429 let wd = WorkflowDefinition {
430 name: String::new(),
431 on: vec![],
432 on_raw: serde_yaml::Value::Null,
433 jobs: Default::default(),
434 defaults: None,
435 env: HashMap::new(),
436 };
437 let info = wd.resolve_action("owner/repo");
438 assert_eq!(info.repository, "owner/repo");
439 assert_eq!(info.version, "main");
440 assert!(info.sub_path.is_none());
441 }
442
443 #[test]
444 fn resolve_action_docker_reference() {
445 let wd = WorkflowDefinition {
446 name: String::new(),
447 on: vec![],
448 on_raw: serde_yaml::Value::Null,
449 jobs: Default::default(),
450 defaults: None,
451 env: HashMap::new(),
452 };
453 let info = wd.resolve_action("docker://alpine:3.18");
454 assert_eq!(info.repository, "docker://alpine:3.18");
455 assert_eq!(info.version, "");
456 assert!(info.is_docker);
457 assert!(!info.is_local);
458 }
459
460 #[test]
461 fn resolve_action_local_path() {
462 let wd = WorkflowDefinition {
463 name: String::new(),
464 on: vec![],
465 on_raw: serde_yaml::Value::Null,
466 jobs: Default::default(),
467 defaults: None,
468 env: HashMap::new(),
469 };
470 let info = wd.resolve_action("./my-action");
471 assert_eq!(info.repository, "./my-action");
472 assert_eq!(info.version, "");
473 assert!(!info.is_docker);
474 assert!(info.is_local);
475 }
476
477 #[test]
478 fn resolve_action_docker_with_digest() {
479 let wd = WorkflowDefinition {
480 name: String::new(),
481 on: vec![],
482 on_raw: serde_yaml::Value::Null,
483 jobs: Default::default(),
484 defaults: None,
485 env: HashMap::new(),
486 };
487 let info = wd.resolve_action("docker://alpine@sha256:abcdef1234567890");
489 assert_eq!(info.repository, "docker://alpine@sha256:abcdef1234567890");
490 assert_eq!(info.version, "");
491 assert!(info.is_docker);
492 assert!(!info.is_local);
493 }
494
495 #[test]
496 fn resolve_action_with_sha_version() {
497 let wd = WorkflowDefinition {
498 name: String::new(),
499 on: vec![],
500 on_raw: serde_yaml::Value::Null,
501 jobs: Default::default(),
502 defaults: None,
503 env: HashMap::new(),
504 };
505 let info = wd.resolve_action("actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675");
506 assert_eq!(info.repository, "actions/checkout");
507 assert_eq!(info.version, "a81bbbf8298c0fa03ea29cdc473d45769f953675");
508 assert!(info.sub_path.is_none());
509 }
510
511 #[test]
512 fn resolve_action_with_sub_path() {
513 let wd = WorkflowDefinition {
514 name: String::new(),
515 on: vec![],
516 on_raw: serde_yaml::Value::Null,
517 jobs: Default::default(),
518 defaults: None,
519 env: HashMap::new(),
520 };
521 let info = wd.resolve_action("owner/repo/path/to/action@v2");
522 assert_eq!(info.repository, "owner/repo");
523 assert_eq!(info.version, "v2");
524 assert_eq!(info.sub_path.as_deref(), Some("path/to/action"));
525 assert!(!info.is_docker);
526 assert!(!info.is_local);
527 }
528
529 #[test]
530 fn resolve_action_with_single_sub_path() {
531 let wd = WorkflowDefinition {
532 name: String::new(),
533 on: vec![],
534 on_raw: serde_yaml::Value::Null,
535 jobs: Default::default(),
536 defaults: None,
537 env: HashMap::new(),
538 };
539 let info = wd.resolve_action("github/codeql-action/init@v3");
540 assert_eq!(info.repository, "github/codeql-action");
541 assert_eq!(info.version, "v3");
542 assert_eq!(info.sub_path.as_deref(), Some("init"));
543 }
544
545 #[test]
546 fn parse_workflow_allows_null_workflow_dispatch_with_other_triggers() {
547 let temp_dir = tempdir().unwrap();
548 let workflow_path = temp_dir.path().join("workflow.yml");
549
550 let content = r#"
551name: trigger-test
552on:
553 push:
554 branches: []
555 tags-ignore: []
556 release:
557 types: [prereleased, published]
558 workflow_dispatch:
559
560jobs:
561 test:
562 runs-on: ubuntu-latest
563 steps:
564 - run: echo hi
565"#;
566
567 fs::write(&workflow_path, content).unwrap();
568
569 let parsed = parse_workflow(&workflow_path);
570 assert!(
571 parsed.is_ok(),
572 "Expected workflow to parse successfully, got: {:?}",
573 parsed.err()
574 );
575 }
576
577 #[test]
578 fn parse_container_string_format() {
579 let temp_dir = tempdir().unwrap();
580 let workflow_path = temp_dir.path().join("workflow.yml");
581
582 let content = r#"
583name: container-test
584on: push
585jobs:
586 test:
587 runs-on: ubuntu-latest
588 container: node:18
589 steps:
590 - run: echo hi
591"#;
592 fs::write(&workflow_path, content).unwrap();
593
594 let parsed = parse_workflow(&workflow_path).unwrap();
595 let job = parsed.jobs.get("test").unwrap();
596 let container = job.container.as_ref().expect("container should be Some");
597 assert_eq!(container.image, "node:18");
598 assert!(container.env.is_empty());
599 assert!(container.credentials.is_none());
600 assert!(container.ports.is_none());
601 assert!(container.volumes.is_none());
602 assert!(container.options.is_none());
603 }
604
605 #[test]
606 fn parse_container_object_format() {
607 let temp_dir = tempdir().unwrap();
608 let workflow_path = temp_dir.path().join("workflow.yml");
609
610 let content = r#"
611name: container-test
612on: push
613jobs:
614 test:
615 runs-on: ubuntu-latest
616 container:
617 image: node:18-alpine
618 credentials:
619 username: user
620 password: pass
621 env:
622 NODE_ENV: production
623 ports:
624 - "8080:80"
625 volumes:
626 - /host/path:/container/path
627 - /single-path
628 options: "--cpus 2"
629 steps:
630 - run: echo hi
631"#;
632 fs::write(&workflow_path, content).unwrap();
633
634 let parsed = parse_workflow(&workflow_path).unwrap();
635 let job = parsed.jobs.get("test").unwrap();
636 let container = job.container.as_ref().expect("container should be Some");
637 assert_eq!(container.image, "node:18-alpine");
638 assert_eq!(container.env.get("NODE_ENV").unwrap(), "production");
639 let creds = container.credentials.as_ref().unwrap();
640 assert_eq!(creds.username.as_deref(), Some("user"));
641 assert_eq!(creds.password.as_deref(), Some("pass"));
642 assert_eq!(
643 container.ports.as_ref().unwrap(),
644 &vec!["8080:80".to_string()]
645 );
646 let volumes = container.volumes.as_ref().unwrap();
647 assert_eq!(volumes.len(), 2);
648 assert_eq!(volumes[0], "/host/path:/container/path");
649 assert_eq!(volumes[1], "/single-path");
650 assert_eq!(container.options.as_deref(), Some("--cpus 2"));
651 }
652
653 #[test]
654 fn parse_container_absent() {
655 let temp_dir = tempdir().unwrap();
656 let workflow_path = temp_dir.path().join("workflow.yml");
657
658 let content = r#"
659name: no-container
660on: push
661jobs:
662 test:
663 runs-on: ubuntu-latest
664 steps:
665 - run: echo hi
666"#;
667 fs::write(&workflow_path, content).unwrap();
668
669 let parsed = parse_workflow(&workflow_path).unwrap();
670 let job = parsed.jobs.get("test").unwrap();
671 assert!(job.container.is_none());
672 }
673
674 #[test]
675 fn parse_container_image_with_colon_in_tag() {
676 let temp_dir = tempdir().unwrap();
677 let workflow_path = temp_dir.path().join("workflow.yml");
678
679 let content = r#"
680name: container-test
681on: push
682jobs:
683 test:
684 runs-on: ubuntu-latest
685 container: ghcr.io/owner/image:latest
686 steps:
687 - run: echo hi
688"#;
689 fs::write(&workflow_path, content).unwrap();
690
691 let parsed = parse_workflow(&workflow_path).unwrap();
692 let job = parsed.jobs.get("test").unwrap();
693 let container = job.container.as_ref().unwrap();
694 assert_eq!(container.image, "ghcr.io/owner/image:latest");
695 }
696
697 #[test]
698 fn container_credentials_serialize_redacts_password() {
699 let creds = ContainerCredentials {
700 username: Some("user".into()),
701 password: Some("super-secret".into()),
702 };
703 let json = serde_json::to_string(&creds).unwrap();
704 assert!(json.contains("user"));
705 assert!(json.contains("[REDACTED]"));
706 assert!(!json.contains("super-secret"));
707 }
708
709 #[test]
710 fn container_credentials_serialize_null_password() {
711 let creds = ContainerCredentials {
712 username: Some("user".into()),
713 password: None,
714 };
715 let json = serde_json::to_string(&creds).unwrap();
716 assert!(json.contains("user"));
717 assert!(!json.contains("[REDACTED]"));
718 }
719
720 #[test]
721 fn parse_step_with_all_new_fields() {
722 let temp_dir = tempdir().unwrap();
723 let workflow_path = temp_dir.path().join("workflow.yml");
724
725 let content = r#"
726name: step-fields-test
727on: push
728jobs:
729 test:
730 runs-on: ubuntu-latest
731 steps:
732 - id: build-step
733 name: Build
734 if: github.ref == 'refs/heads/main'
735 run: cargo build
736 shell: bash
737 working-directory: ./src
738 timeout-minutes: 10.5
739 continue-on-error: true
740"#;
741 fs::write(&workflow_path, content).unwrap();
742
743 let parsed = parse_workflow(&workflow_path).unwrap();
744 let job = parsed.jobs.get("test").unwrap();
745 let step = &job.steps[0];
746 assert_eq!(step.id.as_deref(), Some("build-step"));
747 assert_eq!(
748 step.if_condition.as_deref(),
749 Some("github.ref == 'refs/heads/main'")
750 );
751 assert_eq!(step.shell.as_deref(), Some("bash"));
752 assert_eq!(step.working_directory.as_deref(), Some("./src"));
753 assert_eq!(step.timeout_minutes, Some(10.5));
754 assert_eq!(step.continue_on_error, Some(true));
755 }
756
757 #[test]
758 fn parse_job_timeout_minutes() {
759 let temp_dir = tempdir().unwrap();
760 let workflow_path = temp_dir.path().join("workflow.yml");
761
762 let content = r#"
763name: timeout-test
764on: push
765jobs:
766 build:
767 runs-on: ubuntu-latest
768 timeout-minutes: 30
769 steps:
770 - run: echo hello
771 no-timeout:
772 runs-on: ubuntu-latest
773 steps:
774 - run: echo world
775"#;
776 fs::write(&workflow_path, content).unwrap();
777
778 let parsed = parse_workflow(&workflow_path).unwrap();
779 let build_job = parsed.jobs.get("build").unwrap();
780 assert_eq!(build_job.timeout_minutes, Some(30.0));
781
782 let no_timeout_job = parsed.jobs.get("no-timeout").unwrap();
783 assert_eq!(no_timeout_job.timeout_minutes, None);
784 }
785
786 #[test]
787 fn parse_workflow_defaults() {
788 let temp_dir = tempdir().unwrap();
789 let workflow_path = temp_dir.path().join("workflow.yml");
790
791 let content = r#"
792name: defaults-test
793on: push
794defaults:
795 run:
796 shell: bash
797 working-directory: ./src
798jobs:
799 build:
800 runs-on: ubuntu-latest
801 defaults:
802 run:
803 shell: sh
804 working-directory: ./app
805 steps:
806 - run: echo hello
807 no-defaults:
808 runs-on: ubuntu-latest
809 steps:
810 - run: echo world
811"#;
812 fs::write(&workflow_path, content).unwrap();
813
814 let parsed = parse_workflow(&workflow_path).unwrap();
815
816 let wf_defaults = parsed.defaults.as_ref().unwrap();
818 let wf_run = wf_defaults.run.as_ref().unwrap();
819 assert_eq!(wf_run.shell.as_deref(), Some("bash"));
820 assert_eq!(wf_run.working_directory.as_deref(), Some("./src"));
821
822 let build_job = parsed.jobs.get("build").unwrap();
824 let job_defaults = build_job.defaults.as_ref().unwrap();
825 let job_run = job_defaults.run.as_ref().unwrap();
826 assert_eq!(job_run.shell.as_deref(), Some("sh"));
827 assert_eq!(job_run.working_directory.as_deref(), Some("./app"));
828
829 let no_defaults_job = parsed.jobs.get("no-defaults").unwrap();
831 assert!(no_defaults_job.defaults.is_none());
832 }
833
834 #[test]
835 fn parse_strategy_matrix() {
836 let temp_dir = tempdir().unwrap();
837 let workflow_path = temp_dir.path().join("workflow.yml");
838
839 let content = r#"
840name: matrix-test
841on: push
842jobs:
843 test:
844 runs-on: ubuntu-latest
845 strategy:
846 fail-fast: false
847 max-parallel: 2
848 matrix:
849 os: [ubuntu-latest, windows-latest]
850 node: [16, 18]
851 steps:
852 - run: echo hi
853"#;
854 fs::write(&workflow_path, content).unwrap();
855
856 let parsed = parse_workflow(&workflow_path).unwrap();
857 let job = parsed.jobs.get("test").unwrap();
858 assert!(job.matrix_config().is_some());
859 let matrix = job.matrix_config().unwrap();
860 assert!(matrix.parameters.contains_key("os"));
861 assert!(matrix.parameters.contains_key("node"));
862 assert!(!job.fail_fast());
863 assert_eq!(job.max_parallel(), Some(2));
864 }
865
866 #[test]
867 fn parse_continue_on_error_workflow() {
868 let temp_dir = tempdir().unwrap();
869 let workflow_path = temp_dir.path().join("workflow.yml");
870
871 let content = r#"
872name: Continue On Error Test
873on: [push]
874jobs:
875 test-continue:
876 runs-on: ubuntu-latest
877 steps:
878 - name: Failing step with continue
879 run: exit 1
880 continue-on-error: true
881 - name: Should still run
882 run: echo "I ran after failure"
883 test-if-skip:
884 runs-on: ubuntu-latest
885 steps:
886 - name: Always runs
887 run: echo "hello"
888 - name: Skipped step
889 if: "false"
890 run: echo "should not run"
891 - name: Runs after skip
892 run: echo "after skip"
893"#;
894 fs::write(&workflow_path, content).unwrap();
895
896 let parsed = parse_workflow(&workflow_path).unwrap();
897
898 let job = parsed.jobs.get("test-continue").unwrap();
900 assert_eq!(job.steps.len(), 2);
901 assert_eq!(job.steps[0].continue_on_error, Some(true));
902 assert_eq!(job.steps[1].continue_on_error, None);
903
904 let job2 = parsed.jobs.get("test-if-skip").unwrap();
906 assert_eq!(job2.steps.len(), 3);
907 assert_eq!(job2.steps[0].if_condition, None);
908 assert_eq!(job2.steps[1].if_condition.as_deref(), Some("false"));
909 assert_eq!(job2.steps[2].if_condition, None);
910 }
911}