dataflow_rs/engine/authoring.rs
1//! Authoring-time validation: checking a workflow definition *before* it
2//! reaches [`Engine::build`](crate::Engine::build).
3//!
4//! The engine's own enforcement — parse, [`Workflow::validate`], and
5//! `LoopConfig::validate` — all fires when the *engine* is built. For a host
6//! that stores definitions and builds one engine over many of them, that is the
7//! wrong time (one bad row aborts the whole build, at reload, for every
8//! workflow in the process), the wrong shape (one stringly error, so an
9//! authoring API cannot point a 400 at `tasks[1].tasks[0].id`), and the wrong
10//! cardinality (fail-fast, so the author fixes one violation per round trip).
11//!
12//! [`Workflow::validate_authored`] answers all three, and carries one
13//! guarantee:
14//!
15//! > It returns empty **if and only if** the JSON parses into a [`Workflow`]
16//! > and that workflow validates.
17//!
18//! That biconditional is true *by construction*, not by keeping a rule list in
19//! sync — see the stages below.
20
21use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
22use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
23use crate::engine::secrets::{SECRET_OPERATOR, Secrets};
24use crate::engine::steps::{StepKind, walk_authored_steps};
25use crate::engine::workflow::Workflow;
26use serde_json::Value;
27use std::collections::HashMap;
28use std::fmt;
29
30/// One problem with a workflow definition.
31///
32/// Shared by [`Workflow::validate_authored`] and, from the registry side,
33/// `EngineBuilder::check_workflow`. Both fill what they genuinely know: a
34/// definition check always has an authored coordinate and knows the step id
35/// when the problem concerns a step; a registry check always has a task id and
36/// reports a path relative to that task.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkflowIssue {
39 /// Stable machine-readable classification.
40 pub code: IssueCode,
41 /// Human-readable explanation. Not stable — branch on [`Self::code`].
42 pub message: String,
43 /// Where the problem is. From [`Workflow::validate_authored`] this is the
44 /// coordinate the author typed, rooted at the workflow document:
45 /// `tasks[1].tasks[0].id`.
46 pub path: Option<String>,
47 /// The step this concerns, when it concerns one. Step ids are unique across
48 /// tasks *and* groups, so this identifies a step on its own.
49 pub task_id: Option<String>,
50}
51
52impl WorkflowIssue {
53 fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
54 Self {
55 code,
56 message: message.into(),
57 path: Some(path.into()),
58 task_id: None,
59 }
60 }
61
62 fn with_step(mut self, id: Option<&str>) -> Self {
63 self.task_id = id.map(str::to_string);
64 self
65 }
66}
67
68impl fmt::Display for WorkflowIssue {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match &self.path {
71 Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
72 None => write!(f, "{} [{}]", self.message, self.code.as_str()),
73 }
74 }
75}
76
77/// Why a workflow definition is not loadable.
78///
79/// `#[non_exhaustive]`: a later minor may add a rule, and a host matching on
80/// the codes it cares about should keep compiling. Use [`Self::as_str`] to
81/// serialize.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[non_exhaustive]
84pub enum IssueCode {
85 /// `id` is missing or empty.
86 EmptyWorkflowId,
87 /// `name` is missing or empty.
88 EmptyWorkflowName,
89 /// `tasks` is missing, not an array, or empty.
90 NoTasks,
91 /// A step carries no `id`.
92 MissingStepId,
93 /// Two steps share an id. Groups share the task id namespace.
94 DuplicateStepId,
95 /// A group's `tasks` is not a non-empty array.
96 EmptyGroup,
97 /// A group is nested at or beyond
98 /// [`MAX_GROUP_DEPTH`](crate::engine::steps::MAX_GROUP_DEPTH).
99 GroupTooDeep,
100 /// A task carries no `function`.
101 MissingFunction,
102 /// `function` is not an object, or its `name` is missing or empty.
103 InvalidFunctionName,
104 /// `terminal` is present but not a boolean.
105 InvalidTerminal,
106 /// `loop.increment` is below 1 — the counter would never reach `max`.
107 LoopIncrementTooSmall,
108 /// `loop.max` is not greater than `loop.init` — no sweep could ever run.
109 LoopBoundEmpty,
110 /// `loop.counter` is not a non-empty dotted path.
111 LoopCounterInvalid,
112 /// No handler will dispatch this function name, and it is not a built-in.
113 /// Usually a typo or a handler the host forgot to register.
114 UnknownFunction,
115 /// The name *is* a built-in, but one that ships as a config schema only
116 /// (`http_call`, `enrich`, `publish_kafka`) and no handler is registered
117 /// under it. The workflow builds cleanly and then fails every message.
118 MissingHandler,
119 /// A custom task's `input` does not deserialize into its handler's declared
120 /// `Input` type.
121 InputParse,
122 /// A `Template` field of a custom task's input does not compile.
123 TemplateCompile,
124 /// An expression reads `{"secret": "name"}` and no secret of that name is
125 /// declared on the engine (`EngineBuilder::with_secrets`). Only literal
126 /// names are checked; a dynamic name fails at evaluation instead.
127 UnknownSecret,
128 /// An expression whose result the engine writes to the message or emits to
129 /// a log reads a secret — a `map` mapping, or a `log` message or field. The
130 /// store exists so a value is never recorded; an expression that would
131 /// record it is refused outright, derived or not. Compute derived values in
132 /// a custom handler.
133 SecretInMessageWrite,
134 /// The store passed to
135 /// [`EngineBuilder::with_secrets`](crate::EngineBuilder::with_secrets) is
136 /// not a JSON object, so no name resolves and
137 /// [`EngineBuilder::build`](crate::EngineBuilder::build) will fail.
138 /// Reported by
139 /// [`EngineBuilder::check_workflow`](crate::EngineBuilder::check_workflow)
140 /// *instead of* the [`Self::UnknownSecret`] issues every literal name would
141 /// otherwise produce — the workflow is not what is wrong.
142 InvalidSecretStore,
143 /// The document does not deserialize into a [`Workflow`]. Carries the
144 /// parser's own message, which names the offending field and type.
145 ParseFailed,
146 /// The document parses but [`Workflow::validate`] rejects it. A backstop:
147 /// reaching this means a rule exists that the checks above do not model.
148 ValidateFailed,
149}
150
151impl IssueCode {
152 /// The stable string form, for serializing into an API response.
153 pub fn as_str(&self) -> &'static str {
154 match self {
155 Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
156 Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
157 Self::NoTasks => "NO_TASKS",
158 Self::MissingStepId => "MISSING_STEP_ID",
159 Self::DuplicateStepId => "DUPLICATE_STEP_ID",
160 Self::EmptyGroup => "EMPTY_GROUP",
161 Self::GroupTooDeep => "GROUP_TOO_DEEP",
162 Self::MissingFunction => "MISSING_FUNCTION",
163 Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
164 Self::InvalidTerminal => "INVALID_TERMINAL",
165 Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
166 Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
167 Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
168 Self::UnknownFunction => "UNKNOWN_FUNCTION",
169 Self::MissingHandler => "MISSING_HANDLER",
170 Self::InputParse => "INPUT_PARSE",
171 Self::TemplateCompile => "TEMPLATE_COMPILE",
172 Self::UnknownSecret => "UNKNOWN_SECRET",
173 Self::SecretInMessageWrite => "SECRET_IN_MESSAGE_WRITE",
174 Self::InvalidSecretStore => "INVALID_SECRET_STORE",
175 Self::ParseFailed => "PARSE_FAILED",
176 Self::ValidateFailed => "VALIDATE_FAILED",
177 }
178 }
179}
180
181impl fmt::Display for IssueCode {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 f.write_str(self.as_str())
184 }
185}
186
187impl Workflow {
188 /// Check authored workflow JSON without building an engine.
189 ///
190 /// Returns empty **if and only if** the JSON parses into a [`Workflow`] and
191 /// that workflow validates.
192 ///
193 /// That is the *shape* question, and it is the whole of it. It is not the
194 /// same as "this engine can run it": [`Engine::build`](crate::Engine::build)
195 /// also resolves every task to a handler and parses custom inputs, so a
196 /// structurally perfect definition naming an unregistered function still
197 /// aborts a build. [`Engine::check_workflow`](crate::Engine::check_workflow)
198 /// answers that half; run both.
199 ///
200 /// # How the guarantee holds
201 ///
202 /// Three stages. A structural walk collects *every* semantic violation with
203 /// the coordinate the author typed; if it finds none, the document is then
204 /// actually parsed and validated, and either failure is reported as one
205 /// further issue. The second and third stages are what make the promise
206 /// true by construction: the crate's serde schema is far larger than any
207 /// rule list — a `"priority": "high"` or a `map` task missing `mappings`
208 /// breaks no *semantic* rule and still cannot load — and mirroring it here
209 /// would recreate the very drift this API exists to remove.
210 ///
211 /// # Example
212 ///
213 /// ```
214 /// use dataflow_rs::{IssueCode, Workflow};
215 /// use serde_json::json;
216 ///
217 /// let broken = json!({
218 /// "id": "w", "name": "w", "priority": 0,
219 /// "tasks": [
220 /// {"id": "dup", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
221 /// {"id": "dup", "name": "b", "function": {"name": "map", "input": {"mappings": []}}}
222 /// ]
223 /// });
224 ///
225 /// let issues = Workflow::validate_authored(&broken);
226 /// assert_eq!(issues[0].code, IssueCode::DuplicateStepId);
227 /// assert_eq!(issues[0].path.as_deref(), Some("tasks[1].id"));
228 /// assert_eq!(issues[0].task_id.as_deref(), Some("dup"));
229 /// ```
230 ///
231 /// Every problem is reported, not just the first:
232 ///
233 /// ```
234 /// # use dataflow_rs::{IssueCode, Workflow};
235 /// # use serde_json::json;
236 /// let issues = Workflow::validate_authored(&json!({
237 /// "id": "", "name": "w", "tasks": [{"id": "t", "name": "t"}]
238 /// }));
239 ///
240 /// let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
241 /// assert!(codes.contains(&IssueCode::EmptyWorkflowId));
242 /// assert!(codes.contains(&IssueCode::MissingFunction));
243 /// ```
244 pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
245 let mut issues = check_shape(json);
246 if !issues.is_empty() {
247 return issues;
248 }
249
250 // Stage 2 — the schema is much wider than the rules above, and
251 // enumerating it here would be the mirror this API exists to delete.
252 let workflow: Workflow = match serde_json::from_value(json.clone()) {
253 Ok(w) => w,
254 Err(err) => {
255 issues.push(WorkflowIssue {
256 code: IssueCode::ParseFailed,
257 message: err.to_string(),
258 path: None,
259 task_id: None,
260 });
261 return issues;
262 }
263 };
264
265 // Stage 3 — a backstop. Reaching this means `check_shape` does not
266 // model some rule `validate` enforces; the caller still gets a correct
267 // answer, and the test suite is what keeps this unreachable.
268 if let Err(err) = workflow.validate() {
269 issues.push(WorkflowIssue {
270 code: IssueCode::ValidateFailed,
271 message: err.to_string(),
272 path: None,
273 task_id: None,
274 });
275 }
276 issues
277 }
278}
279
280/// Check a parsed workflow against a handler registry.
281///
282/// Shared by [`crate::EngineBuilder::check_workflow`] and
283/// [`crate::Engine::check_workflow`] so the two cannot answer differently, and
284/// run against the crate's *real* `TemplateCompiler` rather than a host's
285/// reconstruction of one.
286///
287/// `workflow.tasks` is already flattened, so iterating it covers members of
288/// task groups without any extra traversal.
289pub(crate) fn check_against_registry(
290 workflow: &Workflow,
291 registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
292 template_compiler: &TemplateCompiler,
293 secrets: &Secrets,
294) -> Vec<WorkflowIssue> {
295 let mut issues = check_secrets(workflow, secrets);
296
297 for task in &workflow.tasks {
298 let name = task.function.function_name();
299
300 if !can_dispatch_in(registry, name) {
301 // Distinguish the two reasons, because the fixes differ: a
302 // `RequiresHandler` built-in is a real name awaiting a
303 // registration, while anything else is likely a typo.
304 let (code, message) = match builtin_function_kind(name) {
305 Some(BuiltinKind::RequiresHandler) => (
306 IssueCode::MissingHandler,
307 format!(
308 "'{name}' ships as a config schema only — register a handler under \
309 that name, or this workflow will build cleanly and fail every message"
310 ),
311 ),
312 _ => (
313 IssueCode::UnknownFunction,
314 format!("no handler is registered for '{name}', and it is not a built-in"),
315 ),
316 };
317 issues.push(WorkflowIssue {
318 code,
319 message,
320 path: Some("function.name".to_string()),
321 task_id: Some(task.id.clone()),
322 });
323 continue;
324 }
325
326 // Only `Custom` inputs are still raw at this point: the built-in
327 // variants were typed by serde when the workflow parsed.
328 let FunctionConfig::Custom { name, input, .. } = &task.function else {
329 continue;
330 };
331 let Some(handler) = registry.get(name) else {
332 continue;
333 };
334
335 let mut parsed = match handler.parse_input_box(input) {
336 Ok(parsed) => parsed,
337 Err(err) => {
338 issues.push(WorkflowIssue {
339 code: IssueCode::InputParse,
340 message: format!("input does not match the handler's Input type: {err}"),
341 path: Some("function.input".to_string()),
342 task_id: Some(task.id.clone()),
343 });
344 continue;
345 }
346 };
347
348 if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
349 issues.push(WorkflowIssue {
350 code: IssueCode::TemplateCompile,
351 message: format!("a template field does not compile: {err}"),
352 path: Some("function.input".to_string()),
353 task_id: Some(task.id.clone()),
354 });
355 }
356 }
357
358 issues
359}
360
361/// Where an expression's result ends up — what decides whether it may read a
362/// secret.
363#[derive(Clone, Copy, PartialEq, Eq)]
364enum Sink {
365 /// Collapses to a bool the engine acts on: workflow, group and task
366 /// conditions, `validation` rules, `filter`. Nothing of the value survives.
367 Bool,
368 /// Handed to a handler: `Template` fields and integration `*_logic`. What
369 /// happens next is the handler's business.
370 Handler,
371 /// A custom task's whole raw input, handed to the handler untyped. The
372 /// same rule as [`Sink::Handler`]; it differs only in that the document
373 /// is not one expression, so an issue carries the deep path to the
374 /// reference rather than the field.
375 Input,
376 /// Written to the message or emitted to a log by the engine itself:
377 /// `map` mappings, `log` message and fields. Recorded by construction.
378 Message,
379}
380
381impl Sink {
382 /// Whether the engine itself records this expression's result. Such an
383 /// expression may not read a secret *at all* — the store exists so a value
384 /// is never recorded, and there is no static line between a copy and a
385 /// derived value.
386 ///
387 /// The four variants are a map of where an expression's result goes;
388 /// [`Sink::Bool`] and [`Sink::Handler`] answer this question the same way
389 /// today and are kept apart because the reasons differ — nothing survives
390 /// versus the handler owns what happens next.
391 fn records(self) -> bool {
392 matches!(self, Self::Message)
393 }
394
395 /// Whether an issue points at the reference's own deep path rather than at
396 /// the field. True only where the field is not itself a single expression,
397 /// so the field name alone would not locate the reference.
398 fn points_at_reference(self) -> bool {
399 matches!(self, Self::Input)
400 }
401}
402
403/// Check every expression in `workflow` for `{"secret": …}` references.
404///
405/// One implementation for two callers: `Engine::build` refuses a workflow that
406/// produces any issue here, and `check_workflow` reports the same issues, so the
407/// two cannot disagree about what is loadable.
408///
409/// A reference is any single-key object whose key is the reserved operator
410/// name — datalogic compiles exactly that shape as an operator call in
411/// templating mode, at any depth. A string (or one-element array) argument is
412/// a literal name and is checked against `secrets`; anything else is dynamic
413/// and only fails at evaluation. Both forms are refused in a
414/// [`Sink::Message`] expression.
415pub(crate) fn check_secrets(workflow: &Workflow, secrets: &Secrets) -> Vec<WorkflowIssue> {
416 let mut issues = Vec::new();
417 let mut check = |value: &Value, field: &str, task_id: Option<&str>, sink: Sink| {
418 check_expression(value, field, task_id, sink, secrets, &mut issues);
419 };
420
421 check(&workflow.condition, "condition", None, Sink::Bool);
422
423 for task in &workflow.tasks {
424 // Groups opening at this task, outermost first — compiled alongside
425 // the task condition, so checked alongside it.
426 for group in &task.group_starts {
427 let id = Some(group.id.as_str());
428 check(&group.condition, "condition", id, Sink::Bool);
429 }
430
431 let id = Some(task.id.as_str());
432 check(&task.condition, "condition", id, Sink::Bool);
433
434 match &task.function {
435 FunctionConfig::Map { input, .. } => {
436 for (i, mapping) in input.mappings.iter().enumerate() {
437 let field = format!("function.input.mappings[{i}].logic");
438 check(&mapping.logic, &field, id, Sink::Message);
439 }
440 }
441 FunctionConfig::Validation { input, .. } => {
442 for (i, rule) in input.rules.iter().enumerate() {
443 let field = format!("function.input.rules[{i}].logic");
444 check(&rule.logic, &field, id, Sink::Bool);
445 }
446 }
447 FunctionConfig::Filter { input, .. } => {
448 check(&input.condition, "function.input.condition", id, Sink::Bool);
449 }
450 FunctionConfig::Log { input, .. } => {
451 check(&input.message, "function.input.message", id, Sink::Message);
452 // `fields` is a `HashMap`, so iterate it in name order — issue
453 // order is what a host logs, diffs, or asserts on, and what
454 // `refuse_secret_issues` joins into a `build()` error message.
455 let mut names: Vec<&String> = input.fields.keys().collect();
456 names.sort_unstable();
457 for name in names {
458 let field = format!("function.input.fields.{name}");
459 check(&input.fields[name], &field, id, Sink::Message);
460 }
461 }
462 FunctionConfig::HttpCall { input, .. } => {
463 for (name, template) in [
464 ("path_logic", &input.path_logic),
465 ("body_logic", &input.body_logic),
466 ] {
467 if let Some(t) = template {
468 let field = format!("function.input.{name}");
469 check(t.as_json(), &field, id, Sink::Handler);
470 }
471 }
472 }
473 FunctionConfig::Enrich { input, .. } => {
474 if let Some(t) = &input.path_logic {
475 check(t.as_json(), "function.input.path_logic", id, Sink::Handler);
476 }
477 }
478 FunctionConfig::PublishKafka { input, .. } => {
479 for (name, template) in [
480 ("key_logic", &input.key_logic),
481 ("value_logic", &input.value_logic),
482 ] {
483 if let Some(t) = template {
484 let field = format!("function.input.{name}");
485 check(t.as_json(), &field, id, Sink::Handler);
486 }
487 }
488 }
489 FunctionConfig::Custom { input, .. } => {
490 check(input, "function.input", id, Sink::Input);
491 }
492 FunctionConfig::ParseJson { .. }
493 | FunctionConfig::ParseXml { .. }
494 | FunctionConfig::PublishJson { .. }
495 | FunctionConfig::PublishXml { .. } => {}
496 }
497 }
498
499 issues
500}
501
502/// One expression's worth of [`check_secrets`].
503fn check_expression(
504 value: &Value,
505 field: &str,
506 task_id: Option<&str>,
507 sink: Sink,
508 secrets: &Secrets,
509 issues: &mut Vec<WorkflowIssue>,
510) {
511 let mut refs = Vec::new();
512 collect_secret_refs(value, field, &mut refs);
513 if refs.is_empty() {
514 return;
515 }
516 if sink.records() {
517 issues.push(
518 WorkflowIssue::at(
519 IssueCode::SecretInMessageWrite,
520 field,
521 "reads a secret, and the engine records this expression's result — \
522 compute derived values in a custom handler instead",
523 )
524 .with_step(task_id),
525 );
526 return;
527 }
528 for (path, key) in &refs {
529 let Some(key) = key else { continue };
530 if secrets.get(key).is_some() {
531 continue;
532 }
533 let at = if sink.points_at_reference() {
534 path.as_str()
535 } else {
536 field
537 };
538 issues.push(
539 WorkflowIssue::at(
540 IssueCode::UnknownSecret,
541 at,
542 format!("secret '{key}' is not declared on the engine"),
543 )
544 .with_step(task_id),
545 );
546 }
547}
548
549/// Collect every `{"secret": …}` reference under `value` as
550/// `(path, literal name)` — `None` for a dynamic name. Descends into the
551/// argument too, since a dynamic name may itself contain a reference.
552fn collect_secret_refs<'v>(value: &'v Value, path: &str, out: &mut Vec<(String, Option<&'v str>)>) {
553 match value {
554 Value::Object(map) => {
555 if map.len() == 1 {
556 if let Some(arg) = map.get(SECRET_OPERATOR) {
557 let literal = match arg {
558 Value::String(s) => Some(s.as_str()),
559 Value::Array(items) if items.len() == 1 => items[0].as_str(),
560 _ => None,
561 };
562 out.push((path.to_string(), literal));
563 collect_secret_refs(arg, &format!("{path}.{SECRET_OPERATOR}"), out);
564 return;
565 }
566 }
567 for (key, child) in map {
568 collect_secret_refs(child, &format!("{path}.{key}"), out);
569 }
570 }
571 Value::Array(items) => {
572 for (i, child) in items.iter().enumerate() {
573 collect_secret_refs(child, &format!("{path}[{i}]"), out);
574 }
575 }
576 _ => {}
577 }
578}
579
580/// Stage 1: every semantic violation, with authored coordinates.
581fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
582 let mut issues = Vec::new();
583
584 if non_empty_str(json.get("id")).is_none() {
585 issues.push(WorkflowIssue::at(
586 IssueCode::EmptyWorkflowId,
587 "id",
588 "workflow id must be a non-empty string",
589 ));
590 }
591 if non_empty_str(json.get("name")).is_none() {
592 issues.push(WorkflowIssue::at(
593 IssueCode::EmptyWorkflowName,
594 "name",
595 "workflow name must be a non-empty string",
596 ));
597 }
598
599 match json.get("tasks").and_then(Value::as_array) {
600 Some(tasks) if !tasks.is_empty() => {}
601 _ => issues.push(WorkflowIssue::at(
602 IssueCode::NoTasks,
603 "tasks",
604 "workflow must have at least one task",
605 )),
606 }
607
608 check_steps(json.get("tasks").unwrap_or(&Value::Null), &mut issues);
609
610 if let Some(loop_config) = json.get("loop") {
611 check_loop(loop_config, &mut issues);
612 }
613
614 issues
615}
616
617/// Walk the authored step tree, checking each node and the id namespace.
618///
619/// Built on [`walk_authored_steps`], so the group test, the traversal order and
620/// the depth cap have exactly one definition shared with the parser.
621fn check_steps(tasks: &Value, issues: &mut Vec<WorkflowIssue>) {
622 // Step id -> the path that first claimed it.
623 let mut seen: HashMap<&str, String> = HashMap::new();
624
625 for step in walk_authored_steps(tasks) {
626 let id = non_empty_str(step.node.get("id"));
627
628 match id {
629 None => issues.push(
630 WorkflowIssue::at(
631 IssueCode::MissingStepId,
632 format!("{}.id", step.path),
633 "every step needs a non-empty id",
634 )
635 .with_step(None),
636 ),
637 Some(id) => {
638 if let Some(first) = seen.get(id) {
639 issues.push(
640 WorkflowIssue::at(
641 IssueCode::DuplicateStepId,
642 format!("{}.id", step.path),
643 format!(
644 "step id '{id}' is already used at {first} — task groups \
645 share the task id namespace"
646 ),
647 )
648 .with_step(Some(id)),
649 );
650 } else {
651 seen.insert(id, step.path.clone());
652 }
653 }
654 }
655
656 if let Some(terminal) = step.node.get("terminal") {
657 if !terminal.is_boolean() {
658 issues.push(
659 WorkflowIssue::at(
660 IssueCode::InvalidTerminal,
661 format!("{}.terminal", step.path),
662 "terminal must be a boolean",
663 )
664 .with_step(id),
665 );
666 }
667 }
668
669 match step.kind {
670 StepKind::Leaf => check_function(&step.path, step.node, id, issues),
671 StepKind::Group => {
672 // The parser rejects a group whose `tasks` is not a non-empty
673 // array; the walker reports the node so we can say which.
674 let has_members = step
675 .node
676 .get("tasks")
677 .and_then(Value::as_array)
678 .is_some_and(|members| !members.is_empty());
679 if !has_members {
680 issues.push(
681 WorkflowIssue::at(
682 IssueCode::EmptyGroup,
683 format!("{}.tasks", step.path),
684 "a task group's tasks must be a non-empty array — \
685 an empty group can only be a mistake",
686 )
687 .with_step(id),
688 );
689 }
690 }
691 StepKind::TooDeep => issues.push(
692 WorkflowIssue::at(
693 IssueCode::GroupTooDeep,
694 step.path.clone(),
695 format!(
696 "task groups nested deeper than {} levels",
697 crate::engine::steps::MAX_GROUP_DEPTH
698 ),
699 )
700 .with_step(id),
701 ),
702 }
703 }
704}
705
706/// A leaf must carry a `function` object with a non-empty `name`.
707fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
708 let Some(function) = node.get("function") else {
709 issues.push(
710 WorkflowIssue::at(
711 IssueCode::MissingFunction,
712 format!("{path}.function"),
713 "a task needs a function — an element with neither `function` nor \
714 `tasks` is neither a task nor a group",
715 )
716 .with_step(id),
717 );
718 return;
719 };
720
721 if !function.is_object() || non_empty_str(function.get("name")).is_none() {
722 issues.push(
723 WorkflowIssue::at(
724 IssueCode::InvalidFunctionName,
725 format!("{path}.function.name"),
726 "function must be an object with a non-empty name",
727 )
728 .with_step(id),
729 );
730 }
731}
732
733/// The three `LoopConfig::validate` rules, against the authored JSON.
734fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
735 // Absent fields take their serde defaults, which are valid; only a present
736 // field can be wrong here. A non-integer is a *type* error and belongs to
737 // stage 2, so it is deliberately not reported twice.
738 if let Some(increment) = config.get("increment").and_then(Value::as_i64) {
739 if increment < 1 {
740 issues.push(WorkflowIssue::at(
741 IssueCode::LoopIncrementTooSmall,
742 "loop.increment",
743 format!(
744 "loop increment must be >= 1, got {increment} \
745 (a non-advancing counter would never reach max)"
746 ),
747 ));
748 }
749 }
750
751 let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
752 if let Some(max) = config.get("max").and_then(Value::as_i64) {
753 if max <= init {
754 issues.push(WorkflowIssue::at(
755 IssueCode::LoopBoundEmpty,
756 "loop.max",
757 format!(
758 "loop max ({max}) must be greater than init ({init}) — \
759 the bound is half-open, so this could never run a sweep"
760 ),
761 ));
762 }
763 }
764
765 if let Some(counter) = config.get("counter") {
766 if let Some(counter) = counter.as_str() {
767 if counter.is_empty() || counter.split('.').any(str::is_empty) {
768 issues.push(WorkflowIssue::at(
769 IssueCode::LoopCounterInvalid,
770 "loop.counter",
771 format!(
772 "loop counter must be a non-empty temp_data field path, got {counter:?}"
773 ),
774 ));
775 }
776 }
777 }
778}
779
780/// The value at `field` as a non-empty string, if it is one.
781fn non_empty_str(field: Option<&Value>) -> Option<&str> {
782 field.and_then(Value::as_str).filter(|s| !s.is_empty())
783}