use crate::workflow::models::Workflow;
use crate::ServiceResult;
use std::fs;
use std::path::Path;
pub struct WorkflowParser;
impl WorkflowParser {
pub fn from_file<P: AsRef<Path>>(path: P) -> ServiceResult<Workflow> {
let content = fs::read_to_string(path)?;
Self::parse(&content)
}
pub fn parse(content: &str) -> ServiceResult<Workflow> {
let workflow: Workflow = serde_yaml::from_str(content)?;
Ok(workflow)
}
pub fn parse_and_validate(content: &str) -> ServiceResult<Workflow> {
let workflow = Self::parse(content)?;
Self::validate(&workflow)?;
Ok(workflow)
}
pub fn validate(workflow: &Workflow) -> ServiceResult<()> {
for (job_id, job) in &workflow.jobs {
for needed_job in job.needs.to_vec() {
if !workflow.jobs.contains_key(&needed_job) {
return Err(crate::ServiceError::InvalidInput(format!(
"Job '{}' depends on non-existent job '{}'",
job_id, needed_job
)));
}
}
}
Self::check_circular_dependencies(workflow)?;
for (job_id, job) in &workflow.jobs {
if job.steps.is_empty() {
return Err(crate::ServiceError::InvalidInput(format!(
"Job '{}' has no steps",
job_id
)));
}
}
for (job_id, job) in &workflow.jobs {
for (step_idx, step) in job.steps.iter().enumerate() {
let has_run = step.run.is_some();
let has_uses = step.uses.is_some();
if !has_run && !has_uses {
let step_name = step.name.as_deref().unwrap_or("unnamed");
return Err(crate::ServiceError::InvalidInput(format!(
"Step {} '{}' in job '{}' must have either 'run' or 'uses'",
step_idx, step_name, job_id
)));
}
if has_run && has_uses {
let step_name = step.name.as_deref().unwrap_or("unnamed");
return Err(crate::ServiceError::InvalidInput(format!(
"Step {} '{}' in job '{}' cannot have both 'run' and 'uses'",
step_idx, step_name, job_id
)));
}
}
}
Ok(())
}
fn check_circular_dependencies(workflow: &Workflow) -> ServiceResult<()> {
use std::collections::HashSet;
fn has_cycle(
job_id: &str,
workflow: &Workflow,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
) -> Option<String> {
visited.insert(job_id.to_string());
rec_stack.insert(job_id.to_string());
if let Some(job) = workflow.jobs.get(job_id) {
for needed_job in job.needs.to_vec() {
if !visited.contains(&needed_job) {
if let Some(cycle) = has_cycle(&needed_job, workflow, visited, rec_stack) {
return Some(cycle);
}
} else if rec_stack.contains(&needed_job) {
return Some(format!("{} -> {}", job_id, needed_job));
}
}
}
rec_stack.remove(job_id);
None
}
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
for job_id in workflow.jobs.keys() {
if !visited.contains(job_id) {
if let Some(cycle) = has_cycle(job_id, workflow, &mut visited, &mut rec_stack) {
return Err(crate::ServiceError::InvalidInput(format!(
"Circular dependency detected: {}",
cycle
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_workflow() {
let yaml = r#"
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Hello"
"#;
let workflow = WorkflowParser::parse(yaml).unwrap();
assert!(workflow.name.is_none());
assert!(workflow.jobs.contains_key("build"));
}
#[test]
fn test_validate_missing_dependency() {
let yaml = r#"
on: push
jobs:
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying"
"#;
let workflow = WorkflowParser::parse(yaml).unwrap();
let result = WorkflowParser::validate(&workflow);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("non-existent job"));
}
#[test]
fn test_validate_circular_dependency() {
let yaml = r#"
on: push
jobs:
a:
needs: c
runs-on: ubuntu-latest
steps:
- run: echo "a"
b:
needs: a
runs-on: ubuntu-latest
steps:
- run: echo "b"
c:
needs: b
runs-on: ubuntu-latest
steps:
- run: echo "c"
"#;
let workflow = WorkflowParser::parse(yaml).unwrap();
let result = WorkflowParser::validate(&workflow);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Circular dependency"));
}
#[test]
fn test_validate_empty_job() {
let yaml = r#"
on: push
jobs:
empty:
runs-on: ubuntu-latest
steps: []
"#;
let workflow = WorkflowParser::parse(yaml).unwrap();
let result = WorkflowParser::validate(&workflow);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no steps"));
}
#[test]
fn test_validate_step_without_run_or_uses() {
let yaml = r#"
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Invalid step
env:
FOO: bar
"#;
let workflow = WorkflowParser::parse(yaml).unwrap();
let result = WorkflowParser::validate(&workflow);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must have either 'run' or 'uses'"));
}
#[test]
fn test_validate_step_with_both_run_and_uses() {
let yaml = r#"
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Invalid step
run: echo "Hello"
uses: actions/checkout@v4
"#;
let workflow = WorkflowParser::parse(yaml).unwrap();
let result = WorkflowParser::validate(&workflow);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("cannot have both 'run' and 'uses'"));
}
#[test]
fn test_parse_and_validate_success() {
let yaml = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: cargo test
"#;
let result = WorkflowParser::parse_and_validate(yaml);
assert!(result.is_ok());
}
#[test]
fn test_parse_complex_workflow() {
let yaml = r#"
name: Rust CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run check
run: cargo check --all-features
test:
name: Test Suite
needs: check
runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable, beta, nightly]
steps:
- uses: actions/checkout@v4
- name: Run tests
run: cargo test --all-features
env:
RUST_BACKTRACE: 1
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo fmt --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo clippy -- -D warnings
deploy:
name: Deploy
needs: [test, fmt, clippy]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy
run: echo "Deploying..."
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
"#;
let result = WorkflowParser::parse_and_validate(yaml);
assert!(result.is_ok());
let workflow = result.unwrap();
assert_eq!(workflow.name, Some("Rust CI".to_string()));
assert_eq!(workflow.jobs.len(), 5);
let test = workflow.jobs.get("test").unwrap();
assert_eq!(test.needs.to_vec(), vec!["check"]);
let deploy = workflow.jobs.get("deploy").unwrap();
assert_eq!(deploy.needs.to_vec(), vec!["test", "fmt", "clippy"]);
let strategy = test.strategy.as_ref().unwrap();
let matrix = strategy.matrix.as_ref().unwrap();
assert!(matrix.dimensions.contains_key("rust"));
}
}