use crate::engine::compiler::TEMPLATE_KEY_ESCAPE;
use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
use crate::engine::secrets::{SECRET_OPERATOR, Secrets};
use crate::engine::steps::{StepKind, walk_authored_steps};
use crate::engine::task::HaltOn;
use crate::engine::workflow::Workflow;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowIssue {
pub code: IssueCode,
pub message: String,
pub path: Option<String>,
pub task_id: Option<String>,
}
impl WorkflowIssue {
pub fn severity(&self) -> Severity {
self.code.severity()
}
fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
path: Some(path.into()),
task_id: None,
}
}
fn with_step(mut self, id: Option<&str>) -> Self {
self.task_id = id.map(str::to_string);
self
}
}
impl fmt::Display for WorkflowIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.path {
Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
None => write!(f, "{} [{}]", self.message, self.code.as_str()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IssueCode {
EmptyWorkflowId,
EmptyWorkflowName,
NoTasks,
MissingStepId,
DuplicateStepId,
EmptyGroup,
GroupTooDeep,
MissingFunction,
InvalidFunctionName,
InvalidTerminal,
UnguardedValidation,
InvalidHaltOn,
GroupContinueOnError,
LoopIncrementTooSmall,
LoopBoundEmpty,
LoopCounterInvalid,
UnknownFunction,
MissingHandler,
InputParse,
TemplateCompile,
UnknownSecret,
SecretInMessageWrite,
InvalidSecretStore,
DuplicateTemplateKey,
EscapedTemplateKey,
ParseFailed,
ValidateFailed,
}
impl IssueCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
Self::NoTasks => "NO_TASKS",
Self::MissingStepId => "MISSING_STEP_ID",
Self::DuplicateStepId => "DUPLICATE_STEP_ID",
Self::EmptyGroup => "EMPTY_GROUP",
Self::GroupTooDeep => "GROUP_TOO_DEEP",
Self::MissingFunction => "MISSING_FUNCTION",
Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
Self::InvalidTerminal => "INVALID_TERMINAL",
Self::InvalidHaltOn => "INVALID_HALT_ON",
Self::GroupContinueOnError => "GROUP_CONTINUE_ON_ERROR",
Self::UnguardedValidation => "UNGUARDED_VALIDATION",
Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
Self::UnknownFunction => "UNKNOWN_FUNCTION",
Self::MissingHandler => "MISSING_HANDLER",
Self::InputParse => "INPUT_PARSE",
Self::TemplateCompile => "TEMPLATE_COMPILE",
Self::UnknownSecret => "UNKNOWN_SECRET",
Self::SecretInMessageWrite => "SECRET_IN_MESSAGE_WRITE",
Self::InvalidSecretStore => "INVALID_SECRET_STORE",
Self::DuplicateTemplateKey => "DUPLICATE_TEMPLATE_KEY",
Self::EscapedTemplateKey => "ESCAPED_TEMPLATE_KEY",
Self::ParseFailed => "PARSE_FAILED",
Self::ValidateFailed => "VALIDATE_FAILED",
}
}
pub fn severity(self) -> Severity {
match self {
Self::UnguardedValidation | Self::GroupContinueOnError | Self::EscapedTemplateKey => {
Severity::Advisory
}
Self::MissingHandler => Severity::Defect,
Self::UnknownSecret
| Self::SecretInMessageWrite
| Self::DuplicateTemplateKey
| Self::InvalidSecretStore => Severity::Rejected,
Self::UnknownFunction | Self::InputParse | Self::TemplateCompile => Severity::Rejected,
Self::EmptyWorkflowId
| Self::EmptyWorkflowName
| Self::NoTasks
| Self::MissingStepId
| Self::DuplicateStepId
| Self::EmptyGroup
| Self::GroupTooDeep
| Self::MissingFunction
| Self::InvalidFunctionName
| Self::InvalidTerminal
| Self::InvalidHaltOn
| Self::LoopIncrementTooSmall
| Self::LoopBoundEmpty
| Self::LoopCounterInvalid
| Self::ParseFailed
| Self::ValidateFailed => Severity::Rejected,
}
}
}
impl fmt::Display for IssueCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
Rejected,
Defect,
Advisory,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Rejected => "REJECTED",
Self::Defect => "DEFECT",
Self::Advisory => "ADVISORY",
}
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Workflow {
pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
let mut issues = check_shape(json);
if !issues.is_empty() {
return issues;
}
let workflow: Self = match serde_json::from_value(json.clone()) {
Ok(w) => w,
Err(err) => {
issues.push(WorkflowIssue {
code: IssueCode::ParseFailed,
message: err.to_string(),
path: None,
task_id: None,
});
return issues;
}
};
if let Err(err) = workflow.validate() {
issues.push(WorkflowIssue {
code: IssueCode::ValidateFailed,
message: err.to_string(),
path: None,
task_id: None,
});
}
issues
}
}
pub(crate) fn check_against_registry(
workflow: &Workflow,
registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
template_compiler: &TemplateCompiler,
secrets: &Secrets,
) -> Vec<WorkflowIssue> {
let mut issues = check_secrets(workflow, secrets);
issues.extend(check_template_keys(workflow));
check_unguarded_validation(workflow, &mut issues);
check_group_continue_on_error(workflow, &mut issues);
for task in &workflow.tasks {
let name = task.function.function_name();
if !can_dispatch_in(registry, name) {
let (code, message) = match builtin_function_kind(name) {
Some(BuiltinKind::RequiresHandler) => (
IssueCode::MissingHandler,
format!(
"'{name}' ships as a config schema only — register a handler under \
that name, or this workflow will build cleanly and fail every message"
),
),
_ => (
IssueCode::UnknownFunction,
format!("no handler is registered for '{name}', and it is not a built-in"),
),
};
issues.push(WorkflowIssue {
code,
message,
path: Some("function.name".to_string()),
task_id: Some(task.id.clone()),
});
continue;
}
let FunctionConfig::Custom { name, input, .. } = &task.function else {
continue;
};
let Some(handler) = registry.get(name) else {
continue;
};
let mut parsed = match handler.parse_input_box(input) {
Ok(parsed) => parsed,
Err(err) => {
issues.push(WorkflowIssue {
code: IssueCode::InputParse,
message: format!("input does not match the handler's Input type: {err}"),
path: Some("function.input".to_string()),
task_id: Some(task.id.clone()),
});
continue;
}
};
if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
issues.push(WorkflowIssue {
code: IssueCode::TemplateCompile,
message: format!("a template field does not compile: {err}"),
path: Some("function.input".to_string()),
task_id: Some(task.id.clone()),
});
}
}
issues
}
fn check_unguarded_validation(workflow: &Workflow, issues: &mut Vec<WorkflowIssue>) {
fn gates(condition: &Value) -> bool {
condition != &Value::Bool(true)
}
for (i, task) in workflow.tasks.iter().enumerate() {
if !matches!(task.function, FunctionConfig::Validation { .. }) {
continue;
}
if task.terminal || task.halt_on != HaltOn::Never {
continue;
}
let Some(following) = workflow.tasks.get(i + 1..).filter(|t| !t.is_empty()) else {
continue;
};
let guarded = following.iter().any(|next| {
gates(&next.condition)
|| matches!(next.function, FunctionConfig::Filter { .. })
|| next.group_starts.iter().any(|g| gates(&g.condition))
});
if guarded {
continue;
}
issues.push(WorkflowIssue {
code: IssueCode::UnguardedValidation,
message: format!(
"a failing rule here records status 400 and task '{}' still runs — \
`continue_on_error` covers 5xx and Err only. Add `\"halt_on\": \"failure\"` \
to stop the workflow, or gate what follows on \
`metadata.progress.status_code`",
following[0].id
),
path: Some("halt_on".to_string()),
task_id: Some(task.id.clone()),
});
}
}
fn check_group_continue_on_error(workflow: &Workflow, issues: &mut Vec<WorkflowIssue>) {
for task in &workflow.tasks {
for group in task.group_starts.iter().filter(|g| g.continue_on_error) {
issues.push(WorkflowIssue {
code: IssueCode::GroupContinueOnError,
message: format!(
"group '{}' carries continue_on_error, which the engine does not \
honour — error handling is per task and per workflow, and a group \
only gates a span. Put it on the tasks inside the group, or on the \
workflow",
group.id
),
path: Some("continue_on_error".to_string()),
task_id: Some(group.id.clone()),
});
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Sink {
Bool,
Handler,
Input,
Message,
}
impl Sink {
fn records(self) -> bool {
matches!(self, Self::Message)
}
fn points_at_reference(self) -> bool {
matches!(self, Self::Input)
}
}
pub(crate) fn check_secrets(workflow: &Workflow, secrets: &Secrets) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
for_each_expression(workflow, &mut |value, field, task_id, sink| {
check_expression(value, field, task_id, sink, secrets, &mut issues);
});
issues
}
fn names_in_order<V>(map: &HashMap<String, V>) -> Vec<&String> {
let mut names: Vec<&String> = map.keys().collect();
names.sort_unstable();
names
}
fn for_each_expression(
workflow: &Workflow,
check: &mut impl FnMut(&Value, &str, Option<&str>, Sink),
) {
check(&workflow.condition, "condition", None, Sink::Bool);
for task in &workflow.tasks {
for group in &task.group_starts {
let id = Some(group.id.as_str());
check(&group.condition, "condition", id, Sink::Bool);
}
let id = Some(task.id.as_str());
check(&task.condition, "condition", id, Sink::Bool);
match &task.function {
FunctionConfig::Map { input, .. } => {
for (i, mapping) in input.mappings.iter().enumerate() {
let field = format!("function.input.mappings[{i}].logic");
check(&mapping.logic, &field, id, Sink::Message);
let field = format!("function.input.mappings[{i}].path");
check(mapping.path.as_json(), &field, id, Sink::Message);
}
}
FunctionConfig::Validation { input, .. } => {
for (i, rule) in input.rules.iter().enumerate() {
let field = format!("function.input.rules[{i}].logic");
check(&rule.logic, &field, id, Sink::Bool);
let field = format!("function.input.rules[{i}].message");
check(rule.message.as_json(), &field, id, Sink::Message);
}
}
FunctionConfig::Filter { input, .. } => {
check(&input.condition, "function.input.condition", id, Sink::Bool);
}
FunctionConfig::Log { input, .. } => {
check(&input.message, "function.input.message", id, Sink::Message);
for name in names_in_order(&input.fields) {
let field = format!("function.input.fields.{name}");
check(&input.fields[name], &field, id, Sink::Message);
}
}
FunctionConfig::HttpCall { input, .. } => {
check(
input.connector.as_json(),
"function.input.connector",
id,
Sink::Handler,
);
check(
input.timeout_ms.as_json(),
"function.input.timeout_ms",
id,
Sink::Handler,
);
for name in names_in_order(&input.headers) {
let field = format!("function.input.headers.{name}");
check(input.headers[name].as_json(), &field, id, Sink::Handler);
}
for (name, template) in [
("path", &input.path),
("body", &input.body),
("body_format", &input.body_format),
("response_path", &input.response_path),
("response_format", &input.response_format),
] {
if let Some(t) = template {
let field = format!("function.input.{name}");
check(t.as_json(), &field, id, Sink::Handler);
}
}
}
FunctionConfig::Enrich { input, .. } => {
for (name, template) in [
("connector", &input.connector),
("merge_path", &input.merge_path),
("timeout_ms", &input.timeout_ms),
] {
let field = format!("function.input.{name}");
check(template.as_json(), &field, id, Sink::Handler);
}
if let Some(t) = &input.path {
check(t.as_json(), "function.input.path", id, Sink::Handler);
}
}
FunctionConfig::PublishKafka { input, .. } => {
for (name, template) in [("connector", &input.connector), ("topic", &input.topic)] {
let field = format!("function.input.{name}");
check(template.as_json(), &field, id, Sink::Handler);
}
for (name, template) in [("key", &input.key), ("value", &input.value)] {
if let Some(t) = template {
let field = format!("function.input.{name}");
check(t.as_json(), &field, id, Sink::Handler);
}
}
}
FunctionConfig::Custom { input, .. } => {
check(input, "function.input", id, Sink::Input);
}
FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
check(
input.source.as_json(),
"function.input.source",
id,
Sink::Message,
);
check(
input.target.as_json(),
"function.input.target",
id,
Sink::Message,
);
}
FunctionConfig::PublishJson { input, .. }
| FunctionConfig::PublishXml { input, .. } => {
check(
input.source.as_json(),
"function.input.source",
id,
Sink::Message,
);
check(
input.target.as_json(),
"function.input.target",
id,
Sink::Message,
);
check(
input.root_element.as_json(),
"function.input.root_element",
id,
Sink::Message,
);
}
}
}
}
pub(crate) fn check_template_keys(workflow: &Workflow) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
for_each_expression(workflow, &mut |value, field, task_id, sink| {
if sink != Sink::Input {
walk_template_keys(value, field, task_id, &mut issues);
}
});
issues
}
pub(crate) fn refusing_template_key_issues(workflow: &Workflow) -> Vec<WorkflowIssue> {
let mut issues = check_template_keys(workflow);
issues.retain(|i| i.severity() == Severity::Rejected);
issues
}
fn walk_template_keys(
value: &Value,
path: &str,
task_id: Option<&str>,
issues: &mut Vec<WorkflowIssue>,
) {
match value {
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
walk_template_keys(item, &format!("{path}[{i}]"), task_id, issues);
}
}
Value::Object(map) => {
report_escaped_and_duplicate_keys(map, path, task_id, issues);
for (key, child) in map {
walk_template_keys(child, &format!("{path}.{key}"), task_id, issues);
}
}
_ => {}
}
}
fn report_escaped_and_duplicate_keys(
map: &serde_json::Map<String, Value>,
path: &str,
task_id: Option<&str>,
issues: &mut Vec<WorkflowIssue>,
) {
let mut emitted: HashMap<String, &str> = HashMap::new();
for key in map.keys() {
if let Some(stripped) = key.strip_prefix(TEMPLATE_KEY_ESCAPE) {
issues.push(
WorkflowIssue::at(
IssueCode::EscapedTemplateKey,
format!("{path}.{key}"),
format!(
"'{key}' is emitted as '{stripped}' — one \
'{TEMPLATE_KEY_ESCAPE}' is stripped from every template key. Double it \
to '{TEMPLATE_KEY_ESCAPE}{key}' to emit '{key}' itself"
),
)
.with_step(task_id),
);
}
let out = key
.strip_prefix(TEMPLATE_KEY_ESCAPE)
.unwrap_or(key)
.to_string();
if let Some(other) = emitted.insert(out.clone(), key) {
issues.push(
WorkflowIssue::at(
IssueCode::DuplicateTemplateKey,
format!("{path}.{key}"),
format!(
"'{other}' and '{key}' both emit the key '{out}', so this object would \
carry it twice — later reads see only the first while serialization \
emits both"
),
)
.with_step(task_id),
);
}
}
}
fn check_expression(
value: &Value,
field: &str,
task_id: Option<&str>,
sink: Sink,
secrets: &Secrets,
issues: &mut Vec<WorkflowIssue>,
) {
let mut refs = Vec::new();
collect_secret_refs(value, field, &mut refs);
if refs.is_empty() {
return;
}
if sink.records() {
issues.push(
WorkflowIssue::at(
IssueCode::SecretInMessageWrite,
field,
"reads a secret, and the engine records this expression's result — \
compute derived values in a custom handler instead",
)
.with_step(task_id),
);
return;
}
for (path, key) in &refs {
let Some(key) = key else { continue };
if secrets.get(key).is_some() {
continue;
}
let at = if sink.points_at_reference() {
path.as_str()
} else {
field
};
issues.push(
WorkflowIssue::at(
IssueCode::UnknownSecret,
at,
format!("secret '{key}' is not declared on the engine"),
)
.with_step(task_id),
);
}
}
fn collect_secret_refs<'v>(value: &'v Value, path: &str, out: &mut Vec<(String, Option<&'v str>)>) {
match value {
Value::Object(map) => {
if map.len() == 1 {
if let Some(arg) = map.get(SECRET_OPERATOR) {
let literal = match arg {
Value::String(s) => Some(s.as_str()),
Value::Array(items) if items.len() == 1 => items[0].as_str(),
_ => None,
};
out.push((path.to_string(), literal));
collect_secret_refs(arg, &format!("{path}.{SECRET_OPERATOR}"), out);
return;
}
}
for (key, child) in map {
collect_secret_refs(child, &format!("{path}.{key}"), out);
}
}
Value::Array(items) => {
for (i, child) in items.iter().enumerate() {
collect_secret_refs(child, &format!("{path}[{i}]"), out);
}
}
_ => {}
}
}
fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
if non_empty_str(json.get("id")).is_none() {
issues.push(WorkflowIssue::at(
IssueCode::EmptyWorkflowId,
"id",
"workflow id must be a non-empty string",
));
}
if non_empty_str(json.get("name")).is_none() {
issues.push(WorkflowIssue::at(
IssueCode::EmptyWorkflowName,
"name",
"workflow name must be a non-empty string",
));
}
match json.get("tasks").and_then(Value::as_array) {
Some(tasks) if !tasks.is_empty() => {}
_ => issues.push(WorkflowIssue::at(
IssueCode::NoTasks,
"tasks",
"workflow must have at least one task",
)),
}
check_steps(json.get("tasks").unwrap_or(&Value::Null), &mut issues);
if let Some(loop_config) = json.get("loop") {
check_loop(loop_config, &mut issues);
}
issues
}
fn check_steps(tasks: &Value, issues: &mut Vec<WorkflowIssue>) {
let mut seen: HashMap<&str, String> = HashMap::new();
for step in walk_authored_steps(tasks) {
let id = non_empty_str(step.node.get("id"));
match id {
None => issues.push(
WorkflowIssue::at(
IssueCode::MissingStepId,
format!("{}.id", step.path),
"every step needs a non-empty id",
)
.with_step(None),
),
Some(id) => {
if let Some(first) = seen.get(id) {
issues.push(
WorkflowIssue::at(
IssueCode::DuplicateStepId,
format!("{}.id", step.path),
format!(
"step id '{id}' is already used at {first} — task groups \
share the task id namespace"
),
)
.with_step(Some(id)),
);
} else {
seen.insert(id, step.path.clone());
}
}
}
if let Some(terminal) = step.node.get("terminal") {
if !terminal.is_boolean() {
let message = if terminal.as_str() == Some("on_failure") {
"terminal must be a boolean — for \"halt if this task failed\" \
use `\"halt_on\": \"failure\"`, which is the outcome axis"
} else {
"terminal must be a boolean"
};
issues.push(
WorkflowIssue::at(
IssueCode::InvalidTerminal,
format!("{}.terminal", step.path),
message,
)
.with_step(id),
);
}
}
if let Some(halt_on) = step.node.get("halt_on") {
let problem = if matches!(step.kind, StepKind::Group) {
Some(
"halt_on is a per-task outcome rule and a group has no outcome \
of its own — put it on the task that can fail",
)
} else {
HaltOn::deserialize(halt_on)
.err()
.map(|_| "halt_on must be one of \"never\", \"failure\"")
};
if let Some(message) = problem {
issues.push(
WorkflowIssue::at(
IssueCode::InvalidHaltOn,
format!("{}.halt_on", step.path),
message,
)
.with_step(id),
);
}
}
match step.kind {
StepKind::Leaf => check_function(&step.path, step.node, id, issues),
StepKind::Group => {
let has_members = step
.node
.get("tasks")
.and_then(Value::as_array)
.is_some_and(|members| !members.is_empty());
if !has_members {
issues.push(
WorkflowIssue::at(
IssueCode::EmptyGroup,
format!("{}.tasks", step.path),
"a task group's tasks must be a non-empty array — \
an empty group can only be a mistake",
)
.with_step(id),
);
}
}
StepKind::TooDeep => issues.push(
WorkflowIssue::at(
IssueCode::GroupTooDeep,
step.path.clone(),
format!(
"task groups nested deeper than {} levels",
crate::engine::steps::MAX_GROUP_DEPTH
),
)
.with_step(id),
),
}
}
}
fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
let Some(function) = node.get("function") else {
issues.push(
WorkflowIssue::at(
IssueCode::MissingFunction,
format!("{path}.function"),
"a task needs a function — an element with neither `function` nor \
`tasks` is neither a task nor a group",
)
.with_step(id),
);
return;
};
if !function.is_object() || non_empty_str(function.get("name")).is_none() {
issues.push(
WorkflowIssue::at(
IssueCode::InvalidFunctionName,
format!("{path}.function.name"),
"function must be an object with a non-empty name",
)
.with_step(id),
);
}
}
fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
if let Some(increment) = config.get("increment").and_then(Value::as_i64) {
if increment < 1 {
issues.push(WorkflowIssue::at(
IssueCode::LoopIncrementTooSmall,
"loop.increment",
format!(
"loop increment must be >= 1, got {increment} \
(a non-advancing counter would never reach max)"
),
));
}
}
let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
if let Some(max) = config.get("max").and_then(Value::as_i64) {
if max <= init {
issues.push(WorkflowIssue::at(
IssueCode::LoopBoundEmpty,
"loop.max",
format!(
"loop max ({max}) must be greater than init ({init}) — \
the bound is half-open, so this could never run a sweep"
),
));
}
}
if let Some(counter) = config.get("counter") {
if let Some(counter) = counter.as_str() {
if counter.is_empty() || counter.split('.').any(str::is_empty) {
issues.push(WorkflowIssue::at(
IssueCode::LoopCounterInvalid,
"loop.counter",
format!(
"loop counter must be a non-empty temp_data field path, got {counter:?}"
),
));
}
}
}
}
fn non_empty_str(field: Option<&Value>) -> Option<&str> {
field.and_then(Value::as_str).filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
const ALL_CODES: [IssueCode; 27] = [
IssueCode::EmptyWorkflowId,
IssueCode::EmptyWorkflowName,
IssueCode::NoTasks,
IssueCode::MissingStepId,
IssueCode::DuplicateStepId,
IssueCode::EmptyGroup,
IssueCode::GroupTooDeep,
IssueCode::MissingFunction,
IssueCode::InvalidFunctionName,
IssueCode::InvalidTerminal,
IssueCode::UnguardedValidation,
IssueCode::InvalidHaltOn,
IssueCode::GroupContinueOnError,
IssueCode::LoopIncrementTooSmall,
IssueCode::LoopBoundEmpty,
IssueCode::LoopCounterInvalid,
IssueCode::UnknownFunction,
IssueCode::MissingHandler,
IssueCode::InputParse,
IssueCode::TemplateCompile,
IssueCode::UnknownSecret,
IssueCode::SecretInMessageWrite,
IssueCode::InvalidSecretStore,
IssueCode::DuplicateTemplateKey,
IssueCode::EscapedTemplateKey,
IssueCode::ParseFailed,
IssueCode::ValidateFailed,
];
fn ordinal(code: IssueCode) -> usize {
match code {
IssueCode::EmptyWorkflowId => 0,
IssueCode::EmptyWorkflowName => 1,
IssueCode::NoTasks => 2,
IssueCode::MissingStepId => 3,
IssueCode::DuplicateStepId => 4,
IssueCode::EmptyGroup => 5,
IssueCode::GroupTooDeep => 6,
IssueCode::MissingFunction => 7,
IssueCode::InvalidFunctionName => 8,
IssueCode::InvalidTerminal => 9,
IssueCode::UnguardedValidation => 10,
IssueCode::InvalidHaltOn => 11,
IssueCode::GroupContinueOnError => 12,
IssueCode::LoopIncrementTooSmall => 13,
IssueCode::LoopBoundEmpty => 14,
IssueCode::LoopCounterInvalid => 15,
IssueCode::UnknownFunction => 16,
IssueCode::MissingHandler => 17,
IssueCode::InputParse => 18,
IssueCode::TemplateCompile => 19,
IssueCode::UnknownSecret => 20,
IssueCode::SecretInMessageWrite => 21,
IssueCode::InvalidSecretStore => 22,
IssueCode::DuplicateTemplateKey => 23,
IssueCode::EscapedTemplateKey => 24,
IssueCode::ParseFailed => 25,
IssueCode::ValidateFailed => 26,
}
}
#[test]
fn all_codes_lists_every_variant() {
let indices: HashSet<usize> = ALL_CODES.iter().copied().map(ordinal).collect();
assert_eq!(
indices,
(0..ALL_CODES.len()).collect::<HashSet<_>>(),
"ALL_CODES is not a bijection onto the variants `ordinal` knows"
);
}
#[test]
fn the_advisory_and_defect_sets_are_exactly_these() {
let by = |want: Severity| -> HashSet<&'static str> {
ALL_CODES
.iter()
.filter(|c| c.severity() == want)
.map(IssueCode::as_str)
.collect()
};
assert_eq!(
by(Severity::Advisory),
HashSet::from([
"UNGUARDED_VALIDATION",
"GROUP_CONTINUE_ON_ERROR",
"ESCAPED_TEMPLATE_KEY",
]),
"the advisory set is what a host screens on — changing it is a breaking change"
);
assert_eq!(
by(Severity::Defect),
HashSet::from(["MISSING_HANDLER"]),
"only the config-only integrations build clean and then fail every message"
);
assert_eq!(
by(Severity::Rejected).len(),
ALL_CODES.len() - 4,
"every remaining code is a rejection"
);
}
#[test]
fn issue_codes_have_distinct_stable_strings() {
let mut seen = HashSet::new();
for code in ALL_CODES {
assert!(seen.insert(code.as_str()), "duplicate string for {code:?}");
assert_eq!(code.to_string(), code.as_str(), "Display matches as_str");
assert!(
code.as_str()
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_'),
"{code:?} is not SCREAMING_SNAKE"
);
}
}
#[test]
fn severities_have_distinct_stable_strings() {
let all = [Severity::Rejected, Severity::Defect, Severity::Advisory];
let mut seen = HashSet::new();
for severity in all {
assert!(seen.insert(severity.as_str()), "duplicate {severity:?}");
assert_eq!(severity.to_string(), severity.as_str());
assert!(
severity
.as_str()
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_'),
"{severity:?} is not SCREAMING_SNAKE"
);
}
}
#[test]
fn a_workflow_issue_reports_its_codes_severity() {
for code in ALL_CODES {
let issue = WorkflowIssue::at(code, "tasks[0]", "message");
assert_eq!(issue.severity(), code.severity(), "{code:?}");
}
}
}