use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workflow {
pub name: Option<String>,
#[serde(rename = "on")]
pub on: Trigger,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub defaults: Option<Defaults>,
pub jobs: HashMap<String, Job>,
#[serde(default)]
pub permissions: Option<Permissions>,
#[serde(default)]
pub concurrency: Option<Concurrency>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Trigger {
Single(String),
Multiple(Vec<String>),
Detailed(HashMap<String, Option<EventConfig>>),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EventConfig {
#[serde(default)]
pub branches: Vec<String>,
#[serde(default, rename = "branches-ignore")]
pub branches_ignore: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, rename = "tags-ignore")]
pub tags_ignore: Vec<String>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default, rename = "paths-ignore")]
pub paths_ignore: Vec<String>,
#[serde(default)]
pub types: Vec<String>,
#[serde(default)]
pub cron: Option<String>,
#[serde(default)]
pub inputs: HashMap<String, WorkflowInput>,
#[serde(default)]
pub outputs: HashMap<String, WorkflowOutput>,
#[serde(default)]
pub secrets: HashMap<String, WorkflowSecret>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInput {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<Value>,
#[serde(default, rename = "type")]
pub input_type: Option<String>,
#[serde(default)]
pub options: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowOutput {
#[serde(default)]
pub description: Option<String>,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowSecret {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Defaults {
#[serde(default)]
pub run: Option<RunDefaults>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RunDefaults {
#[serde(default)]
pub shell: Option<String>,
#[serde(default, rename = "working-directory")]
pub working_directory: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Permissions {
Level(String),
Granular(HashMap<String, String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Concurrency {
Simple(String),
Detailed {
group: String,
#[serde(default, rename = "cancel-in-progress")]
cancel_in_progress: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub needs: JobNeeds,
#[serde(default, rename = "runs-on")]
pub runs_on: Option<RunsOn>,
#[serde(default, rename = "if")]
pub if_condition: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub defaults: Option<Defaults>,
#[serde(default)]
pub outputs: HashMap<String, String>,
#[serde(default)]
pub strategy: Option<Strategy>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub services: HashMap<String, Service>,
#[serde(default)]
pub container: Option<Container>,
#[serde(default, rename = "timeout-minutes")]
pub timeout_minutes: Option<u32>,
#[serde(default, rename = "continue-on-error")]
pub continue_on_error: ContinueOnError,
#[serde(default)]
pub permissions: Option<Permissions>,
#[serde(default)]
pub concurrency: Option<Concurrency>,
#[serde(default)]
pub environment: Option<Environment>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum JobNeeds {
#[default]
None,
Single(String),
Multiple(Vec<String>),
}
impl JobNeeds {
pub fn to_vec(&self) -> Vec<String> {
match self {
JobNeeds::None => vec![],
JobNeeds::Single(s) => vec![s.clone()],
JobNeeds::Multiple(v) => v.clone(),
}
}
pub fn is_empty(&self) -> bool {
match self {
JobNeeds::None => true,
JobNeeds::Single(_) => false,
JobNeeds::Multiple(v) => v.is_empty(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RunsOn {
Label(String),
Labels(Vec<String>),
Expression(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ContinueOnError {
Bool(bool),
Expression(String),
}
impl Default for ContinueOnError {
fn default() -> Self {
ContinueOnError::Bool(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Strategy {
#[serde(default)]
pub matrix: Option<Matrix>,
#[serde(default = "default_fail_fast", rename = "fail-fast")]
pub fail_fast: bool,
#[serde(default, rename = "max-parallel")]
pub max_parallel: Option<u32>,
}
fn default_fail_fast() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Matrix {
#[serde(flatten)]
pub dimensions: HashMap<String, Vec<Value>>,
#[serde(default)]
pub include: Vec<HashMap<String, Value>>,
#[serde(default)]
pub exclude: Vec<HashMap<String, Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default, rename = "if")]
pub if_condition: Option<String>,
#[serde(default)]
pub run: Option<String>,
#[serde(default)]
pub shell: Option<String>,
#[serde(default, rename = "working-directory")]
pub working_directory: Option<String>,
#[serde(default)]
pub uses: Option<String>,
#[serde(default)]
pub with: HashMap<String, Value>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default, rename = "continue-on-error")]
pub continue_on_error: bool,
#[serde(default, rename = "timeout-minutes")]
pub timeout_minutes: Option<u32>,
}
impl Step {
pub fn display_name(&self) -> String {
if let Some(name) = &self.name {
name.clone()
} else if let Some(uses) = &self.uses {
format!("Run {}", uses)
} else if let Some(run) = &self.run {
let first_line = run.lines().next().unwrap_or(run);
if first_line.len() > 50 {
format!("{}...", &first_line[..47])
} else {
format!("Run {}", first_line)
}
} else {
"Unnamed step".to_string()
}
}
pub fn is_run(&self) -> bool {
self.run.is_some()
}
pub fn is_uses(&self) -> bool {
self.uses.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
pub image: String,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub ports: Vec<String>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub options: Option<String>,
#[serde(default)]
pub credentials: Option<ContainerCredentials>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Container {
Image(String),
Detailed(ContainerConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerConfig {
pub image: String,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub ports: Vec<String>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub options: Option<String>,
#[serde(default)]
pub credentials: Option<ContainerCredentials>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerCredentials {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Environment {
Name(String),
Detailed {
name: String,
#[serde(default)]
url: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_workflow() {
let yaml = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Hello, World!"
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
assert_eq!(workflow.name, Some("CI".to_string()));
assert!(matches!(workflow.on, Trigger::Single(ref s) if s == "push"));
assert!(workflow.jobs.contains_key("build"));
}
#[test]
fn test_parse_workflow_with_multiple_triggers() {
let yaml = r#"
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: cargo test
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(workflow.on, Trigger::Multiple(ref v) if v.len() == 2));
}
#[test]
fn test_parse_workflow_with_detailed_triggers() {
let yaml = r#"
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Building"
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
if let Trigger::Detailed(events) = &workflow.on {
assert!(events.contains_key("push"));
assert!(events.contains_key("pull_request"));
} else {
panic!("Expected detailed trigger");
}
}
#[test]
fn test_parse_job_with_needs() {
let yaml = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: cargo build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: cargo test
deploy:
needs: [build, test]
runs-on: ubuntu-latest
steps:
- run: echo "Deploying"
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
let build = workflow.jobs.get("build").unwrap();
assert!(build.needs.is_empty());
let test = workflow.jobs.get("test").unwrap();
assert_eq!(test.needs.to_vec(), vec!["build"]);
let deploy = workflow.jobs.get("deploy").unwrap();
assert_eq!(deploy.needs.to_vec(), vec!["build", "test"]);
}
#[test]
fn test_parse_matrix_strategy() {
let yaml = r#"
name: Matrix CI
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16, 18, 20]
os: [ubuntu-latest, macos-latest]
steps:
- run: echo "Testing"
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
let job = workflow.jobs.get("test").unwrap();
let strategy = job.strategy.as_ref().unwrap();
let matrix = strategy.matrix.as_ref().unwrap();
assert!(matrix.dimensions.contains_key("node"));
assert!(matrix.dimensions.contains_key("os"));
}
#[test]
fn test_parse_step_with_uses() {
let yaml = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm test
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
let job = workflow.jobs.get("build").unwrap();
assert!(job.steps[0].is_uses());
assert_eq!(job.steps[0].uses, Some("actions/checkout@v4".to_string()));
assert!(job.steps[1].is_uses());
assert!(job.steps[1].with.contains_key("node-version"));
assert!(job.steps[2].is_run());
}
#[test]
fn test_parse_step_with_if_condition() {
let yaml = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Always runs"
- run: echo "Only on main"
if: github.ref == 'refs/heads/main'
- run: echo "On failure"
if: failure()
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
let job = workflow.jobs.get("build").unwrap();
assert!(job.steps[0].if_condition.is_none());
assert_eq!(
job.steps[1].if_condition,
Some("github.ref == 'refs/heads/main'".to_string())
);
assert_eq!(job.steps[2].if_condition, Some("failure()".to_string()));
}
#[test]
fn test_parse_job_outputs() {
let yaml = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- id: version
run: echo "version=1.0.0" >> $GITHUB_OUTPUT
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
let job = workflow.jobs.get("build").unwrap();
assert!(job.outputs.contains_key("version"));
assert_eq!(job.steps[0].id, Some("version".to_string()));
}
#[test]
fn test_parse_services() {
let yaml = r#"
name: CI
on: push
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
steps:
- run: echo "Testing with postgres"
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
let job = workflow.jobs.get("test").unwrap();
let postgres = job.services.get("postgres").unwrap();
assert_eq!(postgres.image, "postgres:15");
assert!(postgres.env.contains_key("POSTGRES_PASSWORD"));
}
#[test]
fn test_parse_env_at_all_levels() {
let yaml = r#"
name: CI
on: push
env:
WORKFLOW_VAR: workflow
jobs:
build:
runs-on: ubuntu-latest
env:
JOB_VAR: job
steps:
- run: echo "Hello"
env:
STEP_VAR: step
"#;
let workflow: Workflow = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
workflow.env.get("WORKFLOW_VAR"),
Some(&"workflow".to_string())
);
let job = workflow.jobs.get("build").unwrap();
assert_eq!(job.env.get("JOB_VAR"), Some(&"job".to_string()));
assert_eq!(job.steps[0].env.get("STEP_VAR"), Some(&"step".to_string()));
}
#[test]
fn test_step_display_name() {
let step_with_name = Step {
id: None,
name: Some("Build project".to_string()),
if_condition: None,
run: Some("cargo build".to_string()),
shell: None,
working_directory: None,
uses: None,
with: HashMap::new(),
env: HashMap::new(),
continue_on_error: false,
timeout_minutes: None,
};
assert_eq!(step_with_name.display_name(), "Build project");
let step_with_uses = Step {
id: None,
name: None,
if_condition: None,
run: None,
shell: None,
working_directory: None,
uses: Some("actions/checkout@v4".to_string()),
with: HashMap::new(),
env: HashMap::new(),
continue_on_error: false,
timeout_minutes: None,
};
assert_eq!(step_with_uses.display_name(), "Run actions/checkout@v4");
let step_with_run = Step {
id: None,
name: None,
if_condition: None,
run: Some("echo hello".to_string()),
shell: None,
working_directory: None,
uses: None,
with: HashMap::new(),
env: HashMap::new(),
continue_on_error: false,
timeout_minutes: None,
};
assert_eq!(step_with_run.display_name(), "Run echo hello");
}
#[test]
fn test_job_needs_to_vec() {
assert!(JobNeeds::None.to_vec().is_empty());
assert_eq!(
JobNeeds::Single("build".to_string()).to_vec(),
vec!["build"]
);
assert_eq!(
JobNeeds::Multiple(vec!["build".to_string(), "test".to_string()]).to_vec(),
vec!["build", "test"]
);
}
}