Skip to main content

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// Custom deserializer for container field that handles both string and object formats
50fn 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)] // Skip deserialization of the 'on' field directly
143    pub on: Vec<String>,
144    #[serde(rename = "on")] // Raw access to the 'on' field for custom handling
145    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    // Reusable workflow (job-level 'uses') support
186    #[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    /// Get the matrix config from strategy, if present
200    pub fn matrix_config(&self) -> Option<&MatrixConfig> {
201        self.strategy.as_ref().and_then(|s| s.matrix.as_ref())
202    }
203
204    /// Get fail-fast setting: strategy-level takes precedence, then matrix-level, default true
205    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    /// Get max-parallel setting: strategy-level takes precedence, then matrix-level
214    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    /// Create a step that runs a shell command with default optional fields.
263    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        // Parse GitHub action reference like "actions/checkout@v3"
283        let is_docker = action_ref.starts_with("docker://");
284        let is_local = action_ref.starts_with("./");
285
286        // Docker references can contain `@sha256:digest` (e.g., `docker://alpine@sha256:abc`).
287        // Don't split on `@` for Docker refs — the full string is the image reference.
288        // Local paths also never have a meaningful `@version`.
289        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        // For GitHub action references, split on the first `@` to get repo and version.
309        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") // Default to main if no version specified
313        };
314
315        // GitHub action refs can include a sub-path: `owner/repo/path/to/action@ref`.
316        // Split into the repo (`owner/repo`) and optional sub-path (`path/to/action`).
317        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    /// The repository identifier (`owner/repo`), Docker image ref, or local path.
340    pub repository: String,
341    /// The git ref (tag, branch, or SHA) for GitHub action references.
342    /// Empty for Docker refs (`docker://...`) and local paths (`./...`).
343    /// Defaults to `"main"` when a GitHub action ref omits `@version`.
344    pub version: String,
345    /// Optional sub-path within the repository for actions like `owner/repo/path@ref`.
346    /// `None` for simple `owner/repo@ref`, Docker refs, and local paths.
347    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    // First validate against schema
354    let validator = SchemaValidator::new()?;
355    validator.validate_workflow(path)?;
356
357    // If validation passes, parse the workflow
358    let content =
359        fs::read_to_string(path).map_err(|e| format!("Failed to read workflow file: {}", e))?;
360
361    // Parse the YAML content
362    let mut workflow: WorkflowDefinition = serde_yaml::from_str(&content)
363        .map_err(|e| format!("Failed to parse workflow structure: {}", e))?;
364
365    // Normalize the trigger events
366    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        // Simple string trigger: on: push
376        serde_yaml::Value::String(event) => {
377            triggers.push(event.clone());
378        }
379        // Array of triggers: on: [push, pull_request]
380        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        // Map of triggers with configuration: on: {push: {branches: [main]}}
388        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        // Docker image references can use @sha256:digest — the full string is the image ref
488        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        // Workflow-level defaults
817        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        // Job-level defaults override workflow defaults
823        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        // Job without defaults
830        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        // Verify continue-on-error parsing
899        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        // Verify step-level if condition parsing
905        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}