use crate::parser::error::{ParseError, ParseResult, ValidationError};
use crate::parser::models::*;
use std::fs;
use std::path::Path;
pub struct AzureParser;
impl AzureParser {
pub fn parse(content: &str) -> ParseResult<Pipeline> {
let raw_value: serde_yaml::Value =
serde_yaml::from_str(content).map_err(|e| ParseError::from_yaml_error(&e, content))?;
let mut pipeline: Pipeline =
serde_yaml::from_str(content).map_err(|e| ParseError::from_yaml_error(&e, content))?;
Self::detect_template_directives(&raw_value, &mut pipeline);
Ok(pipeline)
}
pub fn parse_file<P: AsRef<Path>>(path: P) -> ParseResult<Pipeline> {
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|e| {
ParseError::new(format!("failed to read file: {}", e), 0, 0)
.with_kind(crate::parser::error::ParseErrorKind::IoError)
})?;
Self::parse(&content)
}
pub fn parse_with_templates<P: AsRef<Path>>(path: P, repo_root: P) -> ParseResult<Pipeline> {
let pipeline = Self::parse_file(&path)?;
let mut engine =
crate::parser::template::TemplateEngine::new(repo_root.as_ref().to_path_buf());
engine.resolve_pipeline(pipeline)
}
fn detect_template_directives(raw: &serde_yaml::Value, pipeline: &mut Pipeline) {
let mapping = match raw.as_mapping() {
Some(m) => m,
None => return,
};
if let Some(stages_val) = mapping.get(serde_yaml::Value::String("stages".into())) {
if Self::sequence_has_directives(stages_val) {
pipeline.has_template_directives = true;
}
if let Some(stages_seq) = stages_val.as_sequence() {
let mut parsed_idx = 0;
for stage_val in stages_seq {
if Self::is_directive_item(stage_val) {
continue;
}
if let Some(stage_map) = stage_val.as_mapping() {
if let Some(jobs_val) =
stage_map.get(serde_yaml::Value::String("jobs".into()))
{
if Self::sequence_has_directives(jobs_val) {
if let Some(stage) = pipeline.stages.get_mut(parsed_idx) {
stage.has_template_directives = true;
}
}
if let Some(jobs_seq) = jobs_val.as_sequence() {
Self::detect_job_step_directives(
jobs_seq,
&mut pipeline.stages,
parsed_idx,
);
}
}
}
parsed_idx += 1;
}
}
}
if let Some(jobs_val) = mapping.get(serde_yaml::Value::String("jobs".into())) {
if Self::sequence_has_directives(jobs_val) {
pipeline.has_template_directives = true;
}
}
if let Some(steps_val) = mapping.get(serde_yaml::Value::String("steps".into())) {
if Self::sequence_has_directives(steps_val) {
pipeline.has_template_directives = true;
}
}
}
fn detect_job_step_directives(
raw_jobs_seq: &[serde_yaml::Value],
stages: &mut [Stage],
stage_idx: usize,
) {
let mut parsed_job_idx = 0;
for job_val in raw_jobs_seq {
if Self::is_directive_item(job_val) {
continue;
}
if let Some(job_map) = job_val.as_mapping() {
if let Some(steps_val) = job_map.get(serde_yaml::Value::String("steps".into())) {
if Self::sequence_has_directives(steps_val) {
if let Some(stage) = stages.get_mut(stage_idx) {
if let Some(job) = stage.jobs.get_mut(parsed_job_idx) {
job.has_template_directives = true;
}
}
}
}
}
parsed_job_idx += 1;
}
}
fn sequence_has_directives(value: &serde_yaml::Value) -> bool {
if let Some(seq) = value.as_sequence() {
seq.iter().any(Self::is_directive_item)
} else {
false
}
}
fn is_directive_item(value: &serde_yaml::Value) -> bool {
crate::parser::models::is_template_directive(value)
}
}
pub struct PipelineValidator;
impl PipelineValidator {
pub fn validate(pipeline: &Pipeline) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
if pipeline.stages.is_empty()
&& pipeline.jobs.is_empty()
&& pipeline.steps.is_empty()
&& pipeline.extends.is_none()
&& !pipeline.has_template_directives
{
errors.push(ValidationError::new(
"pipeline must have stages, jobs, steps, or extends",
"pipeline",
));
}
for (i, stage) in pipeline.stages.iter().enumerate() {
Self::validate_stage(stage, &format!("stages[{}]", i), &mut errors);
}
for (i, job) in pipeline.jobs.iter().enumerate() {
Self::validate_job(job, &format!("jobs[{}]", i), &mut errors);
}
for (i, step) in pipeline.steps.iter().enumerate() {
Self::validate_step(step, &format!("steps[{}]", i), &mut errors);
}
Self::validate_stage_dependencies(&pipeline.stages, &mut errors);
Self::validate_job_dependencies(&pipeline.jobs, &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn validate_stage(stage: &Stage, path: &str, errors: &mut Vec<ValidationError>) {
if stage.jobs.is_empty() && stage.template.is_none() && !stage.has_template_directives {
errors.push(
ValidationError::new("stage must have jobs or reference a template", path)
.with_suggestion("add 'jobs:' or 'template:' to the stage"),
);
}
for (i, job) in stage.jobs.iter().enumerate() {
Self::validate_job(job, &format!("{}.jobs[{}]", path, i), errors);
}
}
fn validate_job(job: &Job, path: &str, errors: &mut Vec<ValidationError>) {
if job.job.is_none() && job.deployment.is_none() && job.template.is_none() {
errors.push(
ValidationError::new("job must have 'job:', 'deployment:', or 'template:'", path)
.with_suggestion("add 'job: MyJobName' to identify this job"),
);
}
if job.steps.is_empty()
&& job.template.is_none()
&& job.deployment.is_none()
&& !job.has_template_directives
{
errors.push(
ValidationError::new("job must have steps", path)
.with_suggestion("add 'steps:' to define what the job should do"),
);
}
for (i, step) in job.steps.iter().enumerate() {
Self::validate_step(step, &format!("{}.steps[{}]", path, i), errors);
}
}
fn validate_step(_step: &Step, _path: &str, _errors: &mut Vec<ValidationError>) {
}
fn validate_stage_dependencies(stages: &[Stage], errors: &mut Vec<ValidationError>) {
let stage_names: Vec<&str> = stages.iter().filter_map(|s| s.stage.as_deref()).collect();
for stage in stages {
for dep in stage.depends_on.as_vec() {
if !stage_names.contains(&dep.as_str()) {
errors.push(
ValidationError::new(
format!(
"stage '{}' depends on unknown stage '{}'",
stage.stage.as_deref().unwrap_or("unknown"),
dep
),
format!(
"stages.{}.dependsOn",
stage.stage.as_deref().unwrap_or("unknown")
),
)
.with_suggestion(format!("available stages: {}", stage_names.join(", "))),
);
}
}
}
if let Err(cycle) = Self::detect_cycles(&stage_names, |name| {
stages
.iter()
.find(|s| s.stage.as_deref() == Some(name))
.map(|s| s.depends_on.as_vec())
.unwrap_or_default()
}) {
errors.push(ValidationError::new(
format!("circular dependency detected: {}", cycle.join(" -> ")),
"stages",
));
}
}
fn validate_job_dependencies(jobs: &[Job], errors: &mut Vec<ValidationError>) {
let job_names: Vec<&str> = jobs.iter().filter_map(|j| j.identifier()).collect();
for job in jobs {
let Some(job_name) = job.identifier() else {
continue;
};
for dep in job.depends_on.as_vec() {
if !job_names.contains(&dep.as_str()) {
errors.push(
ValidationError::new(
format!("job '{}' depends on unknown job '{}'", job_name, dep),
format!("jobs.{}.dependsOn", job_name),
)
.with_suggestion(format!("available jobs: {}", job_names.join(", "))),
);
}
}
}
if let Err(cycle) = Self::detect_cycles(&job_names, |name| {
jobs.iter()
.find(|j| j.identifier() == Some(name))
.map(|j| j.depends_on.as_vec())
.unwrap_or_default()
}) {
errors.push(ValidationError::new(
format!("circular dependency detected: {}", cycle.join(" -> ")),
"jobs",
));
}
}
fn detect_cycles<F>(nodes: &[&str], get_deps: F) -> Result<(), Vec<String>>
where
F: Fn(&str) -> Vec<String>,
{
#[derive(Clone, Copy, PartialEq)]
enum NodeState {
Unvisited,
Visiting,
Visited,
}
let mut states: std::collections::HashMap<String, NodeState> = nodes
.iter()
.map(|n| (n.to_string(), NodeState::Unvisited))
.collect();
let mut path: Vec<String> = Vec::new();
fn visit<F>(
node: &str,
states: &mut std::collections::HashMap<String, NodeState>,
path: &mut Vec<String>,
get_deps: &F,
) -> Result<(), Vec<String>>
where
F: Fn(&str) -> Vec<String>,
{
match states.get(node) {
Some(NodeState::Visiting) => {
path.push(node.to_string());
return Err(path.clone());
}
Some(NodeState::Visited) => return Ok(()),
_ => {}
}
states.insert(node.to_string(), NodeState::Visiting);
path.push(node.to_string());
for dep in get_deps(node) {
visit(&dep, states, path, get_deps)?;
}
path.pop();
states.insert(node.to_string(), NodeState::Visited);
Ok(())
}
for node in nodes {
visit(node, &mut states, &mut path, &get_deps)?;
}
Ok(())
}
}
pub fn normalize_pipeline(mut pipeline: Pipeline) -> Pipeline {
if !pipeline.steps.is_empty() && pipeline.jobs.is_empty() && pipeline.stages.is_empty() {
pipeline.jobs = vec![Job {
job: Some("Job".to_string()),
deployment: None,
display_name: None,
depends_on: DependsOn::Default,
condition: None,
strategy: None,
pool: pipeline.pool.clone(),
container: None,
services: std::collections::HashMap::new(),
variables: Vec::new(),
steps: std::mem::take(&mut pipeline.steps),
timeout_in_minutes: None,
cancel_timeout_in_minutes: None,
continue_on_error: BoolOrExpression::default(),
workspace: None,
uses: None,
template: None,
parameters: std::collections::HashMap::new(),
environment: None,
has_template_directives: false,
}];
}
if !pipeline.jobs.is_empty() && pipeline.stages.is_empty() {
pipeline.stages = vec![Stage {
stage: Some("Build".to_string()),
display_name: None,
depends_on: DependsOn::Default,
condition: None,
variables: Vec::new(),
jobs: std::mem::take(&mut pipeline.jobs),
lock_behavior: None,
template: None,
parameters: std::collections::HashMap::new(),
pool: pipeline.pool.clone(),
has_template_directives: false,
}];
}
pipeline
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_pipeline() {
let yaml = r#"
trigger:
- main
pool:
vmImage: ubuntu-latest
steps:
- script: echo Hello, world!
displayName: Run a one-line script
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert!(!pipeline.steps.is_empty());
}
#[test]
fn test_parse_jobs_pipeline() {
let yaml = r#"
trigger:
- main
jobs:
- job: Build
pool:
vmImage: ubuntu-latest
steps:
- script: cargo build
displayName: Build
- job: Test
dependsOn: Build
pool:
vmImage: ubuntu-latest
steps:
- script: cargo test
displayName: Test
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert_eq!(pipeline.jobs.len(), 2);
assert_eq!(pipeline.jobs[0].job, Some("Build".to_string()));
}
#[test]
fn test_parse_stages_pipeline() {
let yaml = r#"
trigger:
- main
stages:
- stage: Build
jobs:
- job: BuildJob
pool:
vmImage: ubuntu-latest
steps:
- script: cargo build
- stage: Deploy
dependsOn: Build
jobs:
- job: DeployJob
pool:
vmImage: ubuntu-latest
steps:
- script: echo deploying
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert_eq!(pipeline.stages.len(), 2);
assert_eq!(pipeline.stages[0].stage, Some("Build".to_string()));
assert_eq!(pipeline.stages[1].stage, Some("Deploy".to_string()));
}
#[test]
fn test_parse_variables() {
let yaml = r#"
variables:
buildConfiguration: Release
buildPlatform: Any CPU
steps:
- script: echo $(buildConfiguration)
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert_eq!(pipeline.variables.len(), 2);
}
#[test]
fn test_parse_task_step() {
let yaml = r#"
steps:
- task: Bash@3
inputs:
targetType: inline
script: echo Hello
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert_eq!(pipeline.steps.len(), 1);
}
#[test]
fn test_parse_matrix_strategy() {
let yaml = r#"
jobs:
- job: Build
strategy:
matrix:
linux:
vmImage: ubuntu-latest
mac:
vmImage: macos-latest
windows:
vmImage: windows-latest
maxParallel: 3
pool:
vmImage: $(vmImage)
steps:
- script: echo Building on $(Agent.OS)
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert!(pipeline.jobs[0].strategy.is_some());
}
#[test]
fn test_normalize_steps_only() {
let yaml = r#"
steps:
- script: echo Hello
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
let normalized = normalize_pipeline(pipeline);
assert_eq!(normalized.stages.len(), 1);
assert_eq!(normalized.stages[0].jobs.len(), 1);
assert_eq!(normalized.stages[0].jobs[0].steps.len(), 1);
}
#[test]
fn test_validate_circular_dependency() {
let yaml = r#"
stages:
- stage: A
dependsOn: C
jobs:
- job: J1
steps:
- script: echo A
- stage: B
dependsOn: A
jobs:
- job: J2
steps:
- script: echo B
- stage: C
dependsOn: B
jobs:
- job: J3
steps:
- script: echo C
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
let result = PipelineValidator::validate(&pipeline);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.message.contains("circular")));
}
#[test]
fn test_parse_error_context() {
let yaml = r#"
trigger:
- main
jobs:
- job: Build
pool:
vmImage: ubuntu-latest
# Missing steps
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
let result = PipelineValidator::validate(&pipeline);
assert!(result.is_err());
}
#[test]
fn test_parse_checkout_step() {
let yaml = r#"
steps:
- checkout: self
clean: true
fetchDepth: 1
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert_eq!(pipeline.steps.len(), 1);
}
#[test]
fn test_parse_container_job() {
let yaml = r#"
jobs:
- job: Build
container: ubuntu:20.04
steps:
- script: echo Hello from container
"#;
let pipeline = AzureParser::parse(yaml).unwrap();
assert!(pipeline.jobs[0].container.is_some());
}
}