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:
2026-03-26 14:14:15 +00:00
parent a3211552a5
commit 821ef2f570
5 changed files with 595 additions and 11 deletions

View File

@@ -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<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
View 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}");
}
}