use serde::{Deserialize, Serialize};
fn default_pipeline_timeout() -> u64 {
30
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct PipelineDefinition {
pub name: String,
pub description: String,
pub steps: Vec<PipelineStep>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct PipelineStep {
pub name: String,
#[serde(rename = "type")]
pub step_type: PipelineStepType,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parse_pattern: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub substeps: Vec<PipelineStep>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_iterations: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub condition_pattern: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_match: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_no_match: Vec<String>,
#[serde(default = "default_pipeline_timeout")]
pub timeout: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum PipelineStepType {
Once, Loop, Foreach, Conditional, }
impl PipelineDefinition {
pub fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("Pipeline name cannot be empty".to_string());
}
if self.steps.is_empty() {
return Err(format!("Pipeline '{}' has no steps", self.name));
}
for (i, step) in self.steps.iter().enumerate() {
step.validate(&format!("{}[{}]", self.name, i))?;
}
Ok(())
}
}
impl PipelineStep {
pub fn validate(&self, path: &str) -> Result<(), String> {
match self.step_type {
PipelineStepType::Once => {
if self.command.is_none() {
return Err(format!("{}: Once requires 'command'", path));
}
}
PipelineStepType::Loop => {
if self.substeps.is_empty() {
return Err(format!("{}: Loop requires 'substeps'", path));
}
if self.exit_pattern.is_none() {
return Err(format!("{}: Loop requires 'exit_pattern'", path));
}
}
PipelineStepType::Foreach => {
if self.parse_pattern.is_none() {
return Err(format!("{}: Foreach requires 'parse_pattern'", path));
}
if self.substeps.is_empty() {
return Err(format!("{}: Foreach requires 'substeps'", path));
}
}
PipelineStepType::Conditional => {
if self.command.is_none() {
return Err(format!("{}: Conditional requires 'command'", path));
}
if self.condition_pattern.is_none() {
return Err(format!(
"{}: Conditional requires 'condition_pattern'",
path
));
}
}
}
if self.timeout == 0 {
return Err(format!("{}: timeout must be > 0", path));
}
if self.timeout > 3600 {
return Err(format!("{}: timeout too high (max 3600)", path));
}
for (i, substep) in self.substeps.iter().enumerate() {
substep.validate(&format!("{}[{}]", path, i))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_validation_empty_steps() {
let pipeline = PipelineDefinition {
name: "test".to_string(),
description: "Test".to_string(),
steps: vec![],
};
assert!(pipeline.validate().is_err());
}
#[test]
fn test_pipeline_validation_empty_name() {
let pipeline = PipelineDefinition {
name: "".to_string(),
description: "Test".to_string(),
steps: vec![PipelineStep {
name: "test".to_string(),
step_type: PipelineStepType::Once,
command: Some("./test.sh".to_string()),
parse_pattern: None,
substeps: Vec::new(),
max_iterations: None,
exit_pattern: None,
condition_pattern: None,
on_match: Vec::new(),
on_no_match: Vec::new(),
timeout: 30,
}],
};
assert!(pipeline.validate().is_err());
}
#[test]
fn test_once_step_validation() {
let step = PipelineStep {
name: "test".to_string(),
step_type: PipelineStepType::Once,
command: Some("./test.sh".to_string()),
parse_pattern: None,
substeps: Vec::new(),
max_iterations: None,
exit_pattern: None,
condition_pattern: None,
on_match: Vec::new(),
on_no_match: Vec::new(),
timeout: 30,
};
assert!(step.validate("test").is_ok());
}
#[test]
fn test_once_step_missing_command() {
let step = PipelineStep {
name: "test".to_string(),
step_type: PipelineStepType::Once,
command: None,
parse_pattern: None,
substeps: Vec::new(),
max_iterations: None,
exit_pattern: None,
condition_pattern: None,
on_match: Vec::new(),
on_no_match: Vec::new(),
timeout: 30,
};
assert!(step.validate("test").is_err());
}
#[test]
fn test_loop_step_missing_exit_pattern() {
let step = PipelineStep {
name: "test".to_string(),
step_type: PipelineStepType::Loop,
command: None,
parse_pattern: None,
substeps: vec![PipelineStep {
name: "inner".to_string(),
step_type: PipelineStepType::Once,
command: Some("./inner.sh".to_string()),
parse_pattern: None,
substeps: Vec::new(),
max_iterations: None,
exit_pattern: None,
condition_pattern: None,
on_match: Vec::new(),
on_no_match: Vec::new(),
timeout: 30,
}],
max_iterations: Some(5),
exit_pattern: None,
condition_pattern: None,
on_match: Vec::new(),
on_no_match: Vec::new(),
timeout: 30,
};
assert!(step.validate("test").is_err());
}
#[test]
fn test_conditional_step_validation() {
let step = PipelineStep {
name: "test".to_string(),
step_type: PipelineStepType::Conditional,
command: Some("./check.sh".to_string()),
parse_pattern: None,
substeps: Vec::new(),
max_iterations: None,
exit_pattern: None,
condition_pattern: Some("MATCH".to_string()),
on_match: vec!["./yes.sh".to_string()],
on_no_match: vec!["./no.sh".to_string()],
timeout: 30,
};
assert!(step.validate("test").is_ok());
}
#[test]
fn test_timeout_zero_invalid() {
let step = PipelineStep {
name: "test".to_string(),
step_type: PipelineStepType::Once,
command: Some("./test.sh".to_string()),
parse_pattern: None,
substeps: Vec::new(),
max_iterations: None,
exit_pattern: None,
condition_pattern: None,
on_match: Vec::new(),
on_no_match: Vec::new(),
timeout: 0,
};
assert!(step.validate("test").is_err());
}
}