diff --git a/wfe-yaml/src/lib.rs b/wfe-yaml/src/lib.rs index e094058..28a3234 100644 --- a/wfe-yaml/src/lib.rs +++ b/wfe-yaml/src/lib.rs @@ -3,6 +3,7 @@ pub mod error; pub mod executors; pub mod interpolation; pub mod schema; +pub mod types; pub mod validation; use std::collections::HashMap; @@ -10,29 +11,78 @@ use std::collections::HashMap; use crate::compiler::CompiledWorkflow; 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( path: &std::path::Path, config: &HashMap, ) -> Result { 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( yaml: &str, config: &HashMap, -) -> Result { +) -> Result, YamlWorkflowError> { // Interpolate variables. let interpolated = interpolation::interpolate(yaml, config)?; - // Parse YAML. - let workflow: schema::YamlWorkflow = serde_yaml::from_str(&interpolated)?; + // Parse YAML as multi-workflow file. + let file: schema::YamlWorkflowFile = serde_yaml::from_str(&interpolated)?; - // Validate. - validation::validate(&workflow.workflow)?; + let specs = resolve_workflow_specs(file)?; - // Compile. - compiler::compile(&workflow.workflow) + // Validate (multi-workflow validation includes per-workflow + cross-references). + 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, +) -> Result { + 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, 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(), + )), + } } diff --git a/wfe-yaml/src/schema.rs b/wfe-yaml/src/schema.rs index db3835e..8005c44 100644 --- a/wfe-yaml/src/schema.rs +++ b/wfe-yaml/src/schema.rs @@ -2,6 +2,17 @@ use std::collections::HashMap; 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, + /// Multiple workflows in one file. + pub workflows: Option>, +} + +/// Legacy single-workflow top-level structure. Kept for backward compatibility +/// with code that deserializes `YamlWorkflow` directly. #[derive(Debug, Deserialize)] pub struct YamlWorkflow { pub workflow: WorkflowSpec, @@ -16,6 +27,13 @@ pub struct WorkflowSpec { #[serde(default)] pub error_behavior: Option, pub steps: Vec, + /// Typed input schema: { field_name: type_string }. + /// Example: `"repo_url": "string"`, `"tags": "list"`. + #[serde(default)] + pub inputs: HashMap, + /// Typed output schema: { field_name: type_string }. + #[serde(default)] + pub outputs: HashMap, /// Allow unknown top-level keys (e.g. `_templates`) for YAML anchors. #[serde(flatten)] pub _extra: HashMap, @@ -90,6 +108,13 @@ pub struct StepConfig { pub containerd_addr: Option, /// CLI binary name for containerd steps: "nerdctl" (default) or "docker". pub cli: Option, + // Workflow (sub-workflow) fields + /// Child workflow ID (for `type: workflow` steps). + #[serde(rename = "workflow")] + pub child_workflow: Option, + /// Child workflow version (for `type: workflow` steps). + #[serde(rename = "workflow_version")] + pub child_version: Option, } /// YAML-level permission configuration for Deno steps. diff --git a/wfe-yaml/src/types.rs b/wfe-yaml/src/types.rs new file mode 100644 index 0000000..67a1e53 --- /dev/null +++ b/wfe-yaml/src/types.rs @@ -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), + List(Box), + Map(Box), +} + +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"`, `"map"`. +/// +/// Supports: +/// - Primitives: `"string"`, `"number"`, `"integer"`, `"bool"`, `"any"` +/// - Optional: `"string?"` -> `Optional(String)` +/// - List: `"list"` -> `List(String)` +/// - Map: `"map"` -> `Map(Number)` +/// - Nested generics: `"list>"` -> `List(List(String))` +pub fn parse_type_string(s: &str) -> Result { + 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?" + 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").unwrap(), + SchemaType::List(Box::new(SchemaType::String)) + ); + } + + #[test] + fn parse_map_number() { + assert_eq!( + parse_type_string("map").unwrap(), + SchemaType::Map(Box::new(SchemaType::Number)) + ); + } + + #[test] + fn parse_nested_list() { + assert_eq!( + parse_type_string("list>").unwrap(), + SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String)))) + ); + } + + #[test] + fn parse_nested_map_in_list() { + assert_eq!( + parse_type_string("list>").unwrap(), + SchemaType::List(Box::new(SchemaType::Map(Box::new(SchemaType::Integer)))) + ); + } + + #[test] + fn parse_optional_list() { + assert_eq!( + parse_type_string("list?").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"); + 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>>").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", "map", "list>"] { + 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").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)) + ); + } +} diff --git a/wfe-yaml/tests/schema.rs b/wfe-yaml/tests/schema.rs index f329fb1..dfdc371 100644 --- a/wfe-yaml/tests/schema.rs +++ b/wfe-yaml/tests/schema.rs @@ -1,4 +1,4 @@ -use wfe_yaml::schema::YamlWorkflow; +use wfe_yaml::schema::{YamlWorkflow, YamlWorkflowFile}; #[test] fn parse_minimal_yaml() { @@ -192,3 +192,153 @@ workflow: assert_eq!(parsed.workflow.id, "template-wf"); 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" + 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" + ); + 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()); +} diff --git a/wfe-yaml/tests/types.rs b/wfe-yaml/tests/types.rs new file mode 100644 index 0000000..bf36031 --- /dev/null +++ b/wfe-yaml/tests/types.rs @@ -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").unwrap(), + SchemaType::List(Box::new(SchemaType::String)) + ); + assert_eq!( + parse_type_string("list").unwrap(), + SchemaType::List(Box::new(SchemaType::Number)) + ); +} + +#[test] +fn parse_map_types() { + assert_eq!( + parse_type_string("map").unwrap(), + SchemaType::Map(Box::new(SchemaType::String)) + ); + assert_eq!( + parse_type_string("map").unwrap(), + SchemaType::Map(Box::new(SchemaType::Any)) + ); +} + +#[test] +fn parse_nested_generics() { + assert_eq!( + parse_type_string("list>").unwrap(), + SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String)))) + ); + assert_eq!( + parse_type_string("map>").unwrap(), + SchemaType::Map(Box::new(SchemaType::List(Box::new(SchemaType::Integer)))) + ); +} + +#[test] +fn parse_optional_generic() { + assert_eq!( + parse_type_string("list?").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").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", + "map", + "list>", + ] { + let parsed = parse_type_string(s).unwrap(); + assert_eq!(parsed.to_string(), *s, "Roundtrip failed for {s}"); + } +}