feat(wfe-yaml): add multi-workflow YAML and typed input/output schemas
YamlWorkflowFile supports both single (workflow:) and multi (workflows:)
formats. WorkflowSpec gains typed inputs/outputs declarations.
Type string parser for inline types ("string?", "list<number>", etc.).
load_workflow_from_str returns Vec<CompiledWorkflow>.
Backward-compatible load_single_workflow_from_str convenience function.
This commit is contained in:
@@ -3,6 +3,7 @@ pub mod error;
|
|||||||
pub mod executors;
|
pub mod executors;
|
||||||
pub mod interpolation;
|
pub mod interpolation;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod types;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -10,29 +11,78 @@ use std::collections::HashMap;
|
|||||||
use crate::compiler::CompiledWorkflow;
|
use crate::compiler::CompiledWorkflow;
|
||||||
use crate::error::YamlWorkflowError;
|
use crate::error::YamlWorkflowError;
|
||||||
|
|
||||||
/// Load a workflow from a YAML file path, applying variable interpolation.
|
/// Load workflows from a YAML file path, applying variable interpolation.
|
||||||
|
/// Returns a Vec of compiled workflows (supports multi-workflow files).
|
||||||
pub fn load_workflow(
|
pub fn load_workflow(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
config: &HashMap<String, serde_json::Value>,
|
config: &HashMap<String, serde_json::Value>,
|
||||||
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
||||||
let yaml = std::fs::read_to_string(path)?;
|
let yaml = std::fs::read_to_string(path)?;
|
||||||
load_workflow_from_str(&yaml, config)
|
load_single_workflow_from_str(&yaml, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a workflow from a YAML string, applying variable interpolation.
|
/// Load workflows from a YAML string, applying variable interpolation.
|
||||||
|
/// Returns a Vec of compiled workflows (supports multi-workflow files).
|
||||||
pub fn load_workflow_from_str(
|
pub fn load_workflow_from_str(
|
||||||
yaml: &str,
|
yaml: &str,
|
||||||
config: &HashMap<String, serde_json::Value>,
|
config: &HashMap<String, serde_json::Value>,
|
||||||
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
) -> Result<Vec<CompiledWorkflow>, YamlWorkflowError> {
|
||||||
// Interpolate variables.
|
// Interpolate variables.
|
||||||
let interpolated = interpolation::interpolate(yaml, config)?;
|
let interpolated = interpolation::interpolate(yaml, config)?;
|
||||||
|
|
||||||
// Parse YAML.
|
// Parse YAML as multi-workflow file.
|
||||||
let workflow: schema::YamlWorkflow = serde_yaml::from_str(&interpolated)?;
|
let file: schema::YamlWorkflowFile = serde_yaml::from_str(&interpolated)?;
|
||||||
|
|
||||||
// Validate.
|
let specs = resolve_workflow_specs(file)?;
|
||||||
validation::validate(&workflow.workflow)?;
|
|
||||||
|
|
||||||
// Compile.
|
// Validate (multi-workflow validation includes per-workflow + cross-references).
|
||||||
compiler::compile(&workflow.workflow)
|
validation::validate_multi(&specs)?;
|
||||||
|
|
||||||
|
// Compile each workflow.
|
||||||
|
let mut results = Vec::with_capacity(specs.len());
|
||||||
|
for spec in &specs {
|
||||||
|
results.push(compiler::compile(spec)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a single workflow from a YAML string. Returns an error if the file
|
||||||
|
/// contains more than one workflow. This is a backward-compatible convenience
|
||||||
|
/// function.
|
||||||
|
pub fn load_single_workflow_from_str(
|
||||||
|
yaml: &str,
|
||||||
|
config: &HashMap<String, serde_json::Value>,
|
||||||
|
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
||||||
|
let mut workflows = load_workflow_from_str(yaml, config)?;
|
||||||
|
if workflows.len() != 1 {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Expected single workflow, got {}",
|
||||||
|
workflows.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(workflows.remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a YamlWorkflowFile into a list of WorkflowSpecs.
|
||||||
|
fn resolve_workflow_specs(
|
||||||
|
file: schema::YamlWorkflowFile,
|
||||||
|
) -> Result<Vec<schema::WorkflowSpec>, YamlWorkflowError> {
|
||||||
|
match (file.workflow, file.workflows) {
|
||||||
|
(Some(single), None) => Ok(vec![single]),
|
||||||
|
(None, Some(multi)) => {
|
||||||
|
if multi.is_empty() {
|
||||||
|
return Err(YamlWorkflowError::Validation(
|
||||||
|
"workflows list is empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(multi)
|
||||||
|
}
|
||||||
|
(Some(_), Some(_)) => Err(YamlWorkflowError::Validation(
|
||||||
|
"Cannot specify both 'workflow' and 'workflows' in the same file".to_string(),
|
||||||
|
)),
|
||||||
|
(None, None) => Err(YamlWorkflowError::Validation(
|
||||||
|
"Must specify either 'workflow' or 'workflows'".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Top-level YAML file structure supporting both single and multi-workflow files.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct YamlWorkflowFile {
|
||||||
|
/// Single workflow (backward compatible).
|
||||||
|
pub workflow: Option<WorkflowSpec>,
|
||||||
|
/// Multiple workflows in one file.
|
||||||
|
pub workflows: Option<Vec<WorkflowSpec>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy single-workflow top-level structure. Kept for backward compatibility
|
||||||
|
/// with code that deserializes `YamlWorkflow` directly.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct YamlWorkflow {
|
pub struct YamlWorkflow {
|
||||||
pub workflow: WorkflowSpec,
|
pub workflow: WorkflowSpec,
|
||||||
@@ -16,6 +27,13 @@ pub struct WorkflowSpec {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub error_behavior: Option<YamlErrorBehavior>,
|
pub error_behavior: Option<YamlErrorBehavior>,
|
||||||
pub steps: Vec<YamlStep>,
|
pub steps: Vec<YamlStep>,
|
||||||
|
/// Typed input schema: { field_name: type_string }.
|
||||||
|
/// Example: `"repo_url": "string"`, `"tags": "list<string>"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub inputs: HashMap<String, String>,
|
||||||
|
/// Typed output schema: { field_name: type_string }.
|
||||||
|
#[serde(default)]
|
||||||
|
pub outputs: HashMap<String, String>,
|
||||||
/// Allow unknown top-level keys (e.g. `_templates`) for YAML anchors.
|
/// Allow unknown top-level keys (e.g. `_templates`) for YAML anchors.
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub _extra: HashMap<String, serde_yaml::Value>,
|
pub _extra: HashMap<String, serde_yaml::Value>,
|
||||||
@@ -90,6 +108,13 @@ pub struct StepConfig {
|
|||||||
pub containerd_addr: Option<String>,
|
pub containerd_addr: Option<String>,
|
||||||
/// CLI binary name for containerd steps: "nerdctl" (default) or "docker".
|
/// CLI binary name for containerd steps: "nerdctl" (default) or "docker".
|
||||||
pub cli: Option<String>,
|
pub cli: Option<String>,
|
||||||
|
// Workflow (sub-workflow) fields
|
||||||
|
/// Child workflow ID (for `type: workflow` steps).
|
||||||
|
#[serde(rename = "workflow")]
|
||||||
|
pub child_workflow: Option<String>,
|
||||||
|
/// Child workflow version (for `type: workflow` steps).
|
||||||
|
#[serde(rename = "workflow_version")]
|
||||||
|
pub child_version: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// YAML-level permission configuration for Deno steps.
|
/// YAML-level permission configuration for Deno steps.
|
||||||
|
|||||||
252
wfe-yaml/src/types.rs
Normal file
252
wfe-yaml/src/types.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/// Parsed type representation for workflow input/output schemas.
|
||||||
|
///
|
||||||
|
/// This mirrors what wfe-core's `SchemaType` will provide, but is self-contained
|
||||||
|
/// so wfe-yaml can parse type strings without depending on wfe-core's schema module.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SchemaType {
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Integer,
|
||||||
|
Bool,
|
||||||
|
Any,
|
||||||
|
Optional(Box<SchemaType>),
|
||||||
|
List(Box<SchemaType>),
|
||||||
|
Map(Box<SchemaType>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SchemaType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SchemaType::String => write!(f, "string"),
|
||||||
|
SchemaType::Number => write!(f, "number"),
|
||||||
|
SchemaType::Integer => write!(f, "integer"),
|
||||||
|
SchemaType::Bool => write!(f, "bool"),
|
||||||
|
SchemaType::Any => write!(f, "any"),
|
||||||
|
SchemaType::Optional(inner) => write!(f, "{inner}?"),
|
||||||
|
SchemaType::List(inner) => write!(f, "list<{inner}>"),
|
||||||
|
SchemaType::Map(inner) => write!(f, "map<{inner}>"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a type string like `"string"`, `"string?"`, `"list<number>"`, `"map<string>"`.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - Primitives: `"string"`, `"number"`, `"integer"`, `"bool"`, `"any"`
|
||||||
|
/// - Optional: `"string?"` -> `Optional(String)`
|
||||||
|
/// - List: `"list<string>"` -> `List(String)`
|
||||||
|
/// - Map: `"map<number>"` -> `Map(Number)`
|
||||||
|
/// - Nested generics: `"list<list<string>>"` -> `List(List(String))`
|
||||||
|
pub fn parse_type_string(s: &str) -> Result<SchemaType, String> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err("Empty type string".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for optional suffix (but not inside generics).
|
||||||
|
if s.ends_with('?') && !s.ends_with(">?") {
|
||||||
|
// Simple optional like "string?"
|
||||||
|
let inner = parse_type_string(&s[..s.len() - 1])?;
|
||||||
|
return Ok(SchemaType::Optional(Box::new(inner)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle optional on generic types like "list<string>?"
|
||||||
|
if s.ends_with(">?") {
|
||||||
|
let inner = parse_type_string(&s[..s.len() - 1])?;
|
||||||
|
return Ok(SchemaType::Optional(Box::new(inner)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for generic types: list<...> or map<...>
|
||||||
|
if let Some(inner_start) = s.find('<') {
|
||||||
|
if !s.ends_with('>') {
|
||||||
|
return Err(format!("Malformed generic type: '{s}' (missing closing '>')"));
|
||||||
|
}
|
||||||
|
let container = &s[..inner_start];
|
||||||
|
let inner_str = &s[inner_start + 1..s.len() - 1];
|
||||||
|
|
||||||
|
let inner_type = parse_type_string(inner_str)?;
|
||||||
|
|
||||||
|
match container {
|
||||||
|
"list" => Ok(SchemaType::List(Box::new(inner_type))),
|
||||||
|
"map" => Ok(SchemaType::Map(Box::new(inner_type))),
|
||||||
|
other => Err(format!("Unknown generic type: '{other}'")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Primitive types.
|
||||||
|
match s {
|
||||||
|
"string" => Ok(SchemaType::String),
|
||||||
|
"number" => Ok(SchemaType::Number),
|
||||||
|
"integer" => Ok(SchemaType::Integer),
|
||||||
|
"bool" => Ok(SchemaType::Bool),
|
||||||
|
"any" => Ok(SchemaType::Any),
|
||||||
|
other => Err(format!("Unknown type: '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_string() {
|
||||||
|
assert_eq!(parse_type_string("string").unwrap(), SchemaType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_number() {
|
||||||
|
assert_eq!(parse_type_string("number").unwrap(), SchemaType::Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_integer() {
|
||||||
|
assert_eq!(parse_type_string("integer").unwrap(), SchemaType::Integer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_bool() {
|
||||||
|
assert_eq!(parse_type_string("bool").unwrap(), SchemaType::Bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_any() {
|
||||||
|
assert_eq!(parse_type_string("any").unwrap(), SchemaType::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("string?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("number?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_list_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_map_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<number>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<list<string>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_map_in_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<map<integer>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::Map(Box::new(SchemaType::Integer))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_type_error() {
|
||||||
|
let result = parse_type_string("foobar");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Unknown type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_generic_error() {
|
||||||
|
let result = parse_type_string("set<string>");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Unknown generic type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_string_error() {
|
||||||
|
let result = parse_type_string("");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Empty type string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_malformed_generic_error() {
|
||||||
|
let result = parse_type_string("list<string");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Malformed generic type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_whitespace_trimmed() {
|
||||||
|
assert_eq!(parse_type_string(" string ").unwrap(), SchemaType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_deeply_nested() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<list<list<bool>>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::List(
|
||||||
|
Box::new(SchemaType::Bool)
|
||||||
|
)))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_roundtrip_primitives() {
|
||||||
|
for type_str in &["string", "number", "integer", "bool", "any"] {
|
||||||
|
let parsed = parse_type_string(type_str).unwrap();
|
||||||
|
assert_eq!(parsed.to_string(), *type_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_roundtrip_generics() {
|
||||||
|
for type_str in &["list<string>", "map<number>", "list<list<string>>"] {
|
||||||
|
let parsed = parse_type_string(type_str).unwrap();
|
||||||
|
assert_eq!(parsed.to_string(), *type_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_optional() {
|
||||||
|
let t = SchemaType::Optional(Box::new(SchemaType::String));
|
||||||
|
assert_eq!(t.to_string(), "string?");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_map_any() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<any>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Any))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_bool() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("bool?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Bool))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use wfe_yaml::schema::YamlWorkflow;
|
use wfe_yaml::schema::{YamlWorkflow, YamlWorkflowFile};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_minimal_yaml() {
|
fn parse_minimal_yaml() {
|
||||||
@@ -192,3 +192,153 @@ workflow:
|
|||||||
assert_eq!(parsed.workflow.id, "template-wf");
|
assert_eq!(parsed.workflow.id, "template-wf");
|
||||||
assert_eq!(parsed.workflow.steps.len(), 1);
|
assert_eq!(parsed.workflow.steps.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Multi-workflow file tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_workflow_file() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: single
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hello
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflowFile = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.is_some());
|
||||||
|
assert!(parsed.workflows.is_none());
|
||||||
|
assert_eq!(parsed.workflow.unwrap().id, "single");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multi_workflow_file() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflows:
|
||||||
|
- id: build-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo build
|
||||||
|
- id: test-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo test
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflowFile = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.is_none());
|
||||||
|
assert!(parsed.workflows.is_some());
|
||||||
|
let workflows = parsed.workflows.unwrap();
|
||||||
|
assert_eq!(workflows.len(), 2);
|
||||||
|
assert_eq!(workflows[0].id, "build-wf");
|
||||||
|
assert_eq!(workflows[1].id, "test-wf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_workflow_with_input_output_schemas() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: typed-wf
|
||||||
|
version: 1
|
||||||
|
inputs:
|
||||||
|
repo_url: string
|
||||||
|
tags: "list<string>"
|
||||||
|
verbose: bool?
|
||||||
|
outputs:
|
||||||
|
artifact_path: string
|
||||||
|
exit_code: integer
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hello
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert_eq!(parsed.workflow.inputs.len(), 3);
|
||||||
|
assert_eq!(parsed.workflow.inputs.get("repo_url").unwrap(), "string");
|
||||||
|
assert_eq!(
|
||||||
|
parsed.workflow.inputs.get("tags").unwrap(),
|
||||||
|
"list<string>"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.workflow.inputs.get("verbose").unwrap(), "bool?");
|
||||||
|
assert_eq!(parsed.workflow.outputs.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.workflow.outputs.get("artifact_path").unwrap(),
|
||||||
|
"string"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.workflow.outputs.get("exit_code").unwrap(),
|
||||||
|
"integer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_workflow_type() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
workflow_version: 2
|
||||||
|
inputs:
|
||||||
|
- name: repo_url
|
||||||
|
path: data.repo
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
assert_eq!(step.step_type.as_deref(), Some("workflow"));
|
||||||
|
let config = step.config.as_ref().unwrap();
|
||||||
|
assert_eq!(config.child_workflow.as_deref(), Some("child-wf"));
|
||||||
|
assert_eq!(config.child_version, Some(2));
|
||||||
|
assert_eq!(step.inputs.len(), 1);
|
||||||
|
assert_eq!(step.outputs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_workflow_step_version_defaults() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let config = parsed.workflow.steps[0].config.as_ref().unwrap();
|
||||||
|
assert_eq!(config.child_workflow.as_deref(), Some("child-wf"));
|
||||||
|
// version not specified, should be None in schema (compiler defaults to 1).
|
||||||
|
assert_eq!(config.child_version, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_inputs_outputs_default() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: no-schema-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hello
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.inputs.is_empty());
|
||||||
|
assert!(parsed.workflow.outputs.is_empty());
|
||||||
|
}
|
||||||
|
|||||||
107
wfe-yaml/tests/types.rs
Normal file
107
wfe-yaml/tests/types.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use wfe_yaml::types::{parse_type_string, SchemaType};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_all_primitives() {
|
||||||
|
assert_eq!(parse_type_string("string").unwrap(), SchemaType::String);
|
||||||
|
assert_eq!(parse_type_string("number").unwrap(), SchemaType::Number);
|
||||||
|
assert_eq!(parse_type_string("integer").unwrap(), SchemaType::Integer);
|
||||||
|
assert_eq!(parse_type_string("bool").unwrap(), SchemaType::Bool);
|
||||||
|
assert_eq!(parse_type_string("any").unwrap(), SchemaType::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_types() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("string?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("integer?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Integer))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_list_types() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<number>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_map_types() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<string>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<any>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Any))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_generics() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<list<string>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<list<integer>>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::List(Box::new(SchemaType::Integer))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_generic() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_type_returns_error() {
|
||||||
|
let err = parse_type_string("foobar").unwrap_err();
|
||||||
|
assert!(err.contains("Unknown type"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_generic_container_returns_error() {
|
||||||
|
let err = parse_type_string("set<string>").unwrap_err();
|
||||||
|
assert!(err.contains("Unknown generic type"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_returns_error() {
|
||||||
|
let err = parse_type_string("").unwrap_err();
|
||||||
|
assert!(err.contains("Empty"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_malformed_generic_returns_error() {
|
||||||
|
let err = parse_type_string("list<string").unwrap_err();
|
||||||
|
assert!(err.contains("Malformed"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_roundtrip() {
|
||||||
|
for s in &[
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"integer",
|
||||||
|
"bool",
|
||||||
|
"any",
|
||||||
|
"list<string>",
|
||||||
|
"map<number>",
|
||||||
|
"list<list<string>>",
|
||||||
|
] {
|
||||||
|
let parsed = parse_type_string(s).unwrap();
|
||||||
|
assert_eq!(parsed.to_string(), *s, "Roundtrip failed for {s}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user