Skip to main content

dataflow_rs/def/
parse.rs

1//! `parse`: one walk over the authored JSON that builds a [`WorkflowDef`] and
2//! is also the linter. With the compat reader on, the reader's pre-pass
3//! rewrites the v3 spellings first, and its issues come first.
4//!
5//! The issues come out in v3's rule order, which is not the key order:
6//!
7//! 1. the workflow `id`;
8//! 2. `name` and `description`;
9//! 3. `tasks`, a non-empty array;
10//! 4. the steps of `tasks`, pre-order. Per node, in v3's order: the id, then
11//!    `terminal`, `halt_on` on a group, `for_each` (and its unknown keys), then
12//!    by kind: the function (and its unknown keys), an empty group, or a group
13//!    too deep. Then the node's other keys, its unknown keys, and its members;
14//! 5. `loop`, in v3's `check_loop` order, then its unknown keys;
15//! 6. the steps of `loop.setup`, walked like `tasks`;
16//! 7. the workflow's other keys, `rollout` with its unknown keys, then the
17//!    workflow's own unknown keys.
18//!
19//! A key whose value is `null` is read by its own rule, and the rules are:
20//!
21//! - `name`, `description`, `loop`, `rollout` and `for_each` treat `null` as
22//!   absent, since a missing value and an explicit one mean the same;
23//! - `condition` keeps it, as the literal expression `null`, which is falsy:
24//!   the step never runs, and a `NULL_CONDITION` Advisory says so;
25//! - `over` is refused;
26//! - `channel`, `priority`, `tags` and `ext` are a `PARSE_FAILED`, since
27//!   `null` is not the type they name.
28
29use super::keys::{Ext, FUNCTION, GROUP, ROLLOUT, TASK, WORKFLOW};
30use super::reader::{self, Rewrite};
31use super::types::{
32    FunctionRef, GroupDef, Logic, NodeHeader, Spelling, StepDef, TaskDef, WorkflowDef, defaults,
33};
34use super::{MAX_GROUP_DEPTH, Rollout};
35use crate::base::any_rejected;
36use crate::base::{AuthoredPath, Issue, IssueCode, PathSeg, Severity, StepKind};
37use chrono::{DateTime, Utc};
38use serde_json::{Map, Value};
39use std::borrow::Cow;
40use std::collections::HashMap;
41use std::sync::LazyLock;
42
43/// How [`parse`] reads a definition.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct ParseOptions {
47    /// Whether the compat reader runs. With it on (the default), each v3
48    /// spelling is rewritten and reported as a `V3_SPELLING` (see
49    /// [`compat`](crate::compat)), and an unknown key takes its default
50    /// severity and moves into the owning node's `ext`.
51    ///
52    /// With it off, nothing is rewritten and nothing moves. Every unknown key
53    /// is Rejected, and so are the v3 keys of a workflow, a task and a group
54    /// (`continue_on_error`, `halt_on`), which are unknown keys to v4. The
55    /// v3 spellings inside a task's `function` are not `parse`'s to refuse:
56    /// `validate` stays the function's name, and a key a built-in's input
57    /// does not know stays in its `input`, for the
58    /// function's lookup and config to refuse when the workflow is compiled.
59    pub compat: bool,
60}
61
62impl Default for ParseOptions {
63    fn default() -> Self {
64        Self { compat: true }
65    }
66}
67
68impl ParseOptions {
69    /// Turns the compat reader on or off.
70    #[must_use]
71    pub fn compat(mut self, on: bool) -> Self {
72        self.compat = on;
73        self
74    }
75}
76
77/// What [`parse`] found.
78#[derive(Clone, Debug, PartialEq)]
79#[non_exhaustive]
80pub struct Parsed {
81    /// The definition: `Some` exactly when no issue is Rejected.
82    pub def: Option<WorkflowDef>,
83    /// Every issue, in rule order, each at its authored path.
84    pub issues: Vec<Issue>,
85}
86
87/// Reads one authored workflow definition. Never fails: every problem is an
88/// issue, and the definition is built exactly when none is Rejected.
89///
90/// Every structural rule is here, at v3's codes and authored paths. A missing
91/// or ill-typed key is `PARSE_FAILED` at its own path. With the compat reader
92/// on, the v3 spellings are rewritten first, each reported as a
93/// `V3_SPELLING` at the v3 key, and the reader's issues come before the
94/// walk's.
95pub fn parse(json: &Value, opts: ParseOptions) -> Parsed {
96    parse_with_rewrites(json, opts).0
97}
98
99/// Reads a definition by value. The compatibility reader rewrites the owned
100/// JSON in place, avoiding the full-document copy required by [`parse`].
101/// The resulting definition and issue ordering are identical to `parse`.
102pub fn parse_owned(json: Value, opts: ParseOptions) -> Parsed {
103    match json {
104        Value::Object(object) => parse_object(Cow::Owned(object), opts).0,
105        other => parse(&other, opts),
106    }
107}
108
109/// [`parse`], keeping the compat reader's rewrites for
110/// [`migrate_v3`](crate::compat::migrate_v3).
111pub(crate) fn parse_with_rewrites(json: &Value, opts: ParseOptions) -> (Parsed, Vec<Rewrite>) {
112    let Some(object) = json.as_object() else {
113        let parsed = Parsed {
114            def: None,
115            issues: vec![Issue::new(
116                IssueCode::ParseFailed,
117                AuthoredPath::root(),
118                "a workflow definition must be a JSON object",
119            )],
120        };
121        return (parsed, Vec::new());
122    };
123    parse_object(Cow::Borrowed(object), opts)
124}
125
126fn parse_object(
127    mut object: Cow<'_, Map<String, Value>>,
128    opts: ParseOptions,
129) -> (Parsed, Vec<Rewrite>) {
130    if !opts.compat {
131        return (walk(&object, false, Vec::new(), HashMap::new()), Vec::new());
132    }
133    // Borrowed input is copied for the reader; owned input is rewritten in
134    // place. The walk builds on the `ext`s the reader moved keys into.
135    let read = reader::read(object.to_mut());
136    (walk(&object, true, read.issues, read.exts), read.rewrites)
137}
138
139/// Reads a fragment: the steps at `root`, as [`parse`] reads a workflow's,
140/// with the compat reader when `opts` has it on and every issue under `root`.
141///
142/// A root under `loop.setup` makes setup steps. Each `tasks` key in `root` is
143/// a step list, and every one but a body's first sits inside a group, so the
144/// depth limit counts the groups above the fragment: `tasks[2].tasks` starts
145/// one group deep, `loop.setup` at the top. Step ids are checked within the
146/// fragment. The steps are `Some` exactly when no issue is Rejected, and a
147/// fragment that is not an array is `PARSE_FAILED` at `root`.
148pub(crate) fn parse_steps(
149    steps: &Value,
150    root: &AuthoredPath,
151    opts: ParseOptions,
152) -> (Option<Vec<StepDef>>, Vec<Issue>) {
153    let Some(items) = steps.as_array() else {
154        let issue = Issue::new(
155            IssueCode::ParseFailed,
156            root.clone(),
157            "steps must be a JSON array",
158        );
159        return (None, vec![issue]);
160    };
161    let (setup, depth) = fragment_place(root).unwrap_or((false, 0));
162    let mut items = Cow::Borrowed(items.as_slice());
163    let (issues, exts) = if opts.compat {
164        // As `parse_with_rewrites` does: the reader rewrites a copy, and the
165        // walk reads the copy and goes on building the `ext`s it began.
166        let read = reader::read_steps(items.to_mut(), root, setup, depth);
167        (read.issues, read.exts)
168    } else {
169        (Vec::new(), HashMap::new())
170    };
171    let mut walk = Walk::new(opts.compat, issues, exts);
172    let steps = walk.steps(&items, root, setup, depth);
173    walk.finish();
174    let rejected = walk.rejected();
175    ((!rejected).then_some(steps), walk.issues)
176}
177
178/// Whether `seg` is the key `name`.
179fn is(seg: &PathSeg, name: &str) -> bool {
180    matches!(seg, PathSeg::Key(key) if &**key == name)
181}
182
183/// Where a fragment at `root` sits: under `loop.setup` or not, and inside how
184/// many groups. `None` when `root` cannot hold steps: a fragment sits at a
185/// `tasks` list or at `loop.setup`, with any number of groups below, so the
186/// grammar is `(loop.setup | tasks)([i].tasks)*`. A path that only ends in
187/// `tasks`, such as `data.tasks`, names no list of steps.
188pub(crate) fn fragment_place(root: &AuthoredPath) -> Option<(bool, usize)> {
189    let (setup, mut rest) = match root.segs() {
190        [loop_, setup, rest @ ..] if is(loop_, "loop") && is(setup, "setup") => (true, rest),
191        [tasks, rest @ ..] if is(tasks, "tasks") => (false, rest),
192        _ => return None,
193    };
194    let mut depth = 0;
195    loop {
196        match rest {
197            [] => return Some((setup, depth)),
198            [index, tasks, tail @ ..]
199                if matches!(index, PathSeg::Index(_)) && is(tasks, "tasks") =>
200            {
201                depth += 1;
202                rest = tail;
203            }
204            _ => return None,
205        }
206    }
207}
208
209/// Whether a step object is a group: it carries a `tasks` key, whatever its
210/// value. The walk and the compat reader classify a step with this alone.
211pub(super) fn is_group(obj: &Map<String, Value>) -> bool {
212    obj.contains_key("tasks")
213}
214
215/// The kind every issue inside a step is stamped with: [`StepKind::Setup`]
216/// for any step of a `loop.setup` list, `setup`, else a group's or a task's.
217pub(super) fn step_kind(obj: &Map<String, Value>, setup: bool) -> StepKind {
218    if setup {
219        StepKind::Setup
220    } else if is_group(obj) {
221        StepKind::Group
222    } else {
223        StepKind::Task
224    }
225}
226
227/// Whether a group `depth` groups deep has its members walked: the walk
228/// refuses a deeper one as `GROUP_TOO_DEEP` and the reader leaves it as
229/// written.
230pub(super) fn descends(depth: usize) -> bool {
231    depth < MAX_GROUP_DEPTH
232}
233
234/// The walk over `object`, its issues following `issues`, with the `ext`s the
235/// reader began.
236fn walk(
237    object: &Map<String, Value>,
238    compat: bool,
239    issues: Vec<Issue>,
240    exts: HashMap<AuthoredPath, Ext>,
241) -> Parsed {
242    let mut walk = Walk::new(compat, issues, exts);
243    let def = walk.workflow(object);
244    walk.finish();
245    let rejected = walk.rejected();
246    Parsed {
247        def: (!rejected).then_some(def),
248        issues: walk.issues,
249    }
250}
251
252/// The walk's state: the issues so far, the step ids seen, and the step the
253/// walk is in, which stamps every issue reported inside it.
254pub(super) struct Walk<'j> {
255    pub(super) compat: bool,
256    issues: Vec<Issue>,
257    /// Step id → the path that first claimed it. One namespace for `tasks`
258    /// and `loop.setup`.
259    seen: HashMap<&'j str, AuthoredPath>,
260    /// The kind and id of the step being walked.
261    step: Option<(StepKind, Option<&'j str>)>,
262    /// The `ext` of each node the compat reader moved a key into, by path.
263    exts: HashMap<AuthoredPath, Ext>,
264}
265
266impl<'j> Walk<'j> {
267    /// A walk whose issues follow `issues`, with the `ext`s the reader began.
268    fn new(compat: bool, issues: Vec<Issue>, exts: HashMap<AuthoredPath, Ext>) -> Self {
269        Self {
270            compat,
271            issues,
272            seen: HashMap::new(),
273            step: None,
274            exts,
275        }
276    }
277
278    /// Ends the walk: every `ext` the reader began must have been taken by
279    /// the node at its path. One left over is a key the reader moved and the
280    /// walk never reached, so the Advisory "kept in `ext`" would be false:
281    /// Rejected, so the definition is not loaded without it.
282    fn finish(&mut self) {
283        debug_assert!(
284            self.exts.is_empty(),
285            "the reader began an ext the walk never took: {:?}",
286            self.exts.keys().collect::<Vec<_>>()
287        );
288        let mut lost: Vec<AuthoredPath> = self.exts.drain().map(|(at, _)| at).collect();
289        lost.sort_by_cached_key(ToString::to_string);
290        for at in lost {
291            self.parse_failed(
292                at,
293                "the compat reader moved keys into this node's ext, but the walk never \
294                 reached the node",
295            );
296        }
297    }
298
299    /// Whether any issue so far is Rejected.
300    fn rejected(&self) -> bool {
301        any_rejected(&self.issues)
302    }
303
304    /// Reports at the code's default severity.
305    pub(super) fn report(
306        &mut self,
307        code: IssueCode,
308        path: AuthoredPath,
309        message: impl Into<String>,
310    ) {
311        self.report_as(code, code.severity(), path, message);
312    }
313
314    /// Reports at `severity`, stamped with the step being walked.
315    pub(super) fn report_as(
316        &mut self,
317        code: IssueCode,
318        severity: Severity,
319        path: AuthoredPath,
320        message: impl Into<String>,
321    ) {
322        let mut issue = Issue::new(code, path, message).with_severity(severity);
323        if let Some((kind, id)) = self.step {
324            issue = issue.with_step(kind, id);
325        }
326        self.issues.push(issue);
327    }
328
329    /// A key of the wrong type, or a required key missing.
330    pub(super) fn parse_failed(&mut self, path: AuthoredPath, message: impl Into<String>) {
331        self.report(IssueCode::ParseFailed, path, message);
332    }
333
334    /// The `ext` of the node at `at`: the one the reader began, else the
335    /// authored one.
336    fn ext(&mut self, at: &AuthoredPath, obj: &Map<String, Value>) -> Ext {
337        self.exts
338            .remove(at)
339            .unwrap_or_else(|| Ext::authored(obj.get("ext")))
340    }
341
342    fn workflow(&mut self, obj: &'j Map<String, Value>) -> WorkflowDef {
343        let root = AuthoredPath::root();
344        let mut ext = self.ext(&root, obj);
345
346        // 1.
347        let id = non_empty_str(obj.get("id")).map(str::to_string);
348        if id.is_none() {
349            self.report(
350                IssueCode::EmptyWorkflowId,
351                root.join("id"),
352                "workflow id must be a non-empty string",
353            );
354        }
355
356        // 2.
357        let header = self.header(obj, &root);
358
359        // 3.
360        let tasks = obj.get("tasks").and_then(Value::as_array);
361        if !tasks.is_some_and(|tasks| !tasks.is_empty()) {
362            self.report(
363                IssueCode::NoTasks,
364                root.join("tasks"),
365                "workflow must have at least one task",
366            );
367        }
368
369        // 4.
370        let tasks = self.steps(
371            tasks.map_or(&[], Vec::as_slice),
372            &root.join("tasks"),
373            false,
374            0,
375        );
376        self.step = None;
377
378        // 5 and 6.
379        let r#loop = match obj.get("loop") {
380            None | Some(Value::Null) => None,
381            Some(Value::Object(config)) => Some(self.r#loop(config, &mut ext)),
382            Some(_) => {
383                self.parse_failed(root.join("loop"), "expected an object");
384                None
385            }
386        };
387        self.step = None;
388
389        // 7.
390        let priority = self.u32_or(obj, "priority", &root, defaults::PRIORITY);
391        let version = self.u32_or(obj, "version", &root, defaults::VERSION);
392        let channel = self.string_or(obj, "channel", &root, defaults::CHANNEL);
393        let status = self.spelled(obj, "status", &root);
394        let on_failure = self.spelled(obj, "on_failure", &root);
395        let rollout = self.rollout(obj.get("rollout"), &mut ext);
396        let tags = self.tags(obj.get("tags"));
397        let created_at = self.timestamp(obj, "created_at");
398        let updated_at = self.timestamp(obj, "updated_at");
399        self.ext_is_object(obj, &root);
400        self.unknown_keys(obj, &WORKFLOW, &[], &root, "", &mut ext);
401
402        WorkflowDef {
403            id: id.unwrap_or_default(),
404            header: header.with_ext(ext),
405            priority,
406            condition: self.condition(obj, &root),
407            tasks,
408            on_failure,
409            channel,
410            status,
411            version,
412            rollout,
413            r#loop,
414            tags,
415            created_at,
416            updated_at,
417        }
418    }
419
420    /// The steps of a list, `tasks` or `loop.setup`, at `at`.
421    pub(super) fn steps(
422        &mut self,
423        items: &'j [Value],
424        at: &AuthoredPath,
425        setup: bool,
426        depth: usize,
427    ) -> Vec<StepDef> {
428        items
429            .iter()
430            .enumerate()
431            .map(|(i, node)| self.step(node, at.join(i), setup, depth))
432            .collect()
433    }
434
435    /// One step, then its members. An element that is not an object is a
436    /// task with neither id nor function, as v3's walker reads it.
437    fn step(&mut self, node: &'j Value, at: AuthoredPath, setup: bool, depth: usize) -> StepDef {
438        static EMPTY: LazyLock<Map<String, Value>> = LazyLock::new(Map::new);
439        let obj: &'j Map<String, Value> = node.as_object().unwrap_or(&EMPTY);
440        let group = is_group(obj);
441        let id = non_empty_str(obj.get("id"));
442        let kind = step_kind(obj, setup);
443        self.step = Some((kind, id));
444        let mut ext = self.ext(&at, obj);
445
446        // 4.2: the id and its namespace.
447        match id {
448            None => self.report(
449                IssueCode::MissingStepId,
450                at.join("id"),
451                "every step needs a non-empty id",
452            ),
453            Some(id) => {
454                if let Some(first) = self.seen.get(id) {
455                    let message = format!(
456                        "step id '{id}' is already used at {first} — task groups share the \
457                         task id namespace"
458                    );
459                    self.report(IssueCode::DuplicateStepId, at.join("id"), message);
460                } else {
461                    self.seen.insert(id, at.clone());
462                }
463            }
464        }
465
466        // 4.3
467        let terminal = match obj.get("terminal") {
468            None => false,
469            Some(Value::Bool(terminal)) => *terminal,
470            Some(other) => {
471                // `"on_failure"` is the natural wrong guess for an outcome
472                // rule; answer it rather than report a bare type error.
473                let message = if other.as_str() == Some("on_failure") {
474                    "terminal must be a boolean — for \"halt if this task failed\" use \
475                     `\"halt_on\": \"failure\"`, which is the outcome axis"
476                } else {
477                    "terminal must be a boolean"
478                };
479                self.report(IssueCode::InvalidTerminal, at.join("terminal"), message);
480                false
481            }
482        };
483
484        let id = id.unwrap_or_default().to_string();
485        if group {
486            let head = GroupHead {
487                at,
488                id,
489                terminal,
490                ext,
491                setup,
492                depth,
493            };
494            self.group(obj, head)
495        } else {
496            // 4.5
497            let function_name = obj
498                .get("function")
499                .and_then(|f| f.get("name"))
500                .and_then(Value::as_str);
501            let for_each = self.for_each(obj.get("for_each"), function_name, &at, &mut ext);
502            // 4.6
503            let function = self.function(obj, &at, &mut ext);
504            // 4.8
505            let header = self.header(obj, &at);
506            let condition = self.condition(obj, &at);
507            let on_failure = self.spelled(obj, "on_failure", &at);
508            let on_rejection = self.spelled(obj, "on_rejection", &at);
509            self.ext_is_object(obj, &at);
510            self.unknown_keys(obj, &TASK, &[], &at, "", &mut ext);
511            StepDef::Task(TaskDef {
512                id,
513                header: header.with_ext(ext),
514                condition,
515                function,
516                on_failure,
517                on_rejection,
518                terminal,
519                for_each,
520            })
521        }
522    }
523
524    /// The rest of a group, from 4.4 on, then its members.
525    fn group(&mut self, obj: &'j Map<String, Value>, head: GroupHead) -> StepDef {
526        let GroupHead {
527            at,
528            id,
529            terminal,
530            mut ext,
531            setup,
532            depth,
533        } = head;
534        // 4.4
535        if obj.contains_key("halt_on") {
536            self.report(
537                IssueCode::InvalidHaltOn,
538                at.join("halt_on"),
539                "halt_on is a per-task outcome rule and a group has no outcome of its own — \
540                 put it on the task that can fail",
541            );
542        }
543        // 4.5
544        if obj.contains_key("for_each") {
545            self.report(
546                IssueCode::InvalidForEach,
547                at.join("for_each"),
548                "a task group cannot carry for_each — for_each runs one task's function once \
549                 per element; put it on the task",
550            );
551        }
552        // 4.1 (v3 reports the depth after the node's other rules) and 4.7.
553        let too_deep = !descends(depth);
554        let members = obj.get("tasks").and_then(Value::as_array);
555        if too_deep {
556            self.report(
557                IssueCode::GroupTooDeep,
558                at.clone(),
559                format!("task groups nested deeper than {MAX_GROUP_DEPTH} levels"),
560            );
561        } else if !members.is_some_and(|members| !members.is_empty()) {
562            self.report(
563                IssueCode::EmptyGroup,
564                at.join("tasks"),
565                "a task group's tasks must be a non-empty array — an empty group can only be \
566                 a mistake",
567            );
568        }
569        // 4.8
570        let header = self.header(obj, &at);
571        let condition = self.condition(obj, &at);
572        self.ext_is_object(obj, &at);
573        // `halt_on` and `for_each` were refused above, not unknown.
574        self.unknown_keys(obj, &GROUP, &["halt_on", "for_each"], &at, "", &mut ext);
575
576        // A group too deep is not descended into.
577        let tasks = if too_deep {
578            Vec::new()
579        } else {
580            self.steps(
581                members.map_or(&[], Vec::as_slice),
582                &at.join("tasks"),
583                setup,
584                depth + 1,
585            )
586        };
587        StepDef::Group(GroupDef {
588            id,
589            header: header.with_ext(ext),
590            condition,
591            terminal,
592            tasks,
593        })
594    }
595
596    /// 4.6: a task's function, and its unknown keys.
597    fn function(
598        &mut self,
599        obj: &Map<String, Value>,
600        at: &AuthoredPath,
601        ext: &mut Ext,
602    ) -> FunctionRef {
603        let missing = FunctionRef {
604            name: String::new(),
605            input: Value::Object(Map::new()),
606        };
607        let Some(raw) = obj.get("function") else {
608            self.report(
609                IssueCode::MissingFunction,
610                at.join("function"),
611                "a task needs a function — an element with neither `function` nor `tasks` is \
612                 neither a task nor a group",
613            );
614            return missing;
615        };
616        let at = at.join("function");
617        let Some(function) = raw.as_object() else {
618            self.invalid_function_name(&at);
619            return missing;
620        };
621        let name = non_empty_str(function.get("name"));
622        if name.is_none() {
623            self.invalid_function_name(&at);
624        }
625        self.unknown_keys(function, &FUNCTION, &[], &at, "function.", ext);
626        FunctionRef {
627            name: name.unwrap_or_default().to_string(),
628            input: function
629                .get("input")
630                .cloned()
631                .unwrap_or_else(|| Value::Object(Map::new())),
632        }
633    }
634
635    fn invalid_function_name(&mut self, at: &AuthoredPath) {
636        self.report(
637            IssueCode::InvalidFunctionName,
638            at.join("name"),
639            "function must be an object with a non-empty name",
640        );
641    }
642
643    /// A node's `name` and `description`, with an empty `ext` for now.
644    fn header(&mut self, obj: &Map<String, Value>, at: &AuthoredPath) -> NodeHeader {
645        NodeHeader {
646            name: self.opt_string(obj, "name", at),
647            description: self.opt_string(obj, "description", at),
648            ext: Map::new(),
649        }
650    }
651
652    /// A node's `condition`: absent is none, and a literal `null` is kept as
653    /// the falsy expression it is, with an Advisory.
654    fn condition(&mut self, obj: &Map<String, Value>, at: &AuthoredPath) -> Option<Logic> {
655        match obj.get("condition") {
656            None => None,
657            Some(Value::Null) => {
658                self.report(
659                    IssueCode::NullCondition,
660                    at.join("condition"),
661                    "a literal `null` condition is falsy, so the step never runs; omit it for no \
662                     condition",
663                );
664                Some(Logic(Value::Null))
665            }
666            Some(value) => Some(Logic(value.clone())),
667        }
668    }
669
670    /// `ext`, when present, is an object. Its entries were read up front.
671    fn ext_is_object(&mut self, obj: &Map<String, Value>, at: &AuthoredPath) {
672        if obj.get("ext").is_some_and(|ext| !ext.is_object()) {
673            self.parse_failed(at.join("ext"), "expected an object");
674        }
675    }
676
677    /// An optional string: absent or `null` is `None`.
678    pub(super) fn opt_string(
679        &mut self,
680        obj: &Map<String, Value>,
681        key: &str,
682        at: &AuthoredPath,
683    ) -> Option<String> {
684        match obj.get(key) {
685            None | Some(Value::Null) => None,
686            Some(Value::String(text)) => Some(text.clone()),
687            Some(_) => {
688                self.parse_failed(at.join(key), "expected a string");
689                None
690            }
691        }
692    }
693
694    fn string_or(
695        &mut self,
696        obj: &Map<String, Value>,
697        key: &str,
698        at: &AuthoredPath,
699        default: &str,
700    ) -> String {
701        match obj.get(key) {
702            None => default.to_string(),
703            Some(Value::String(text)) => text.clone(),
704            Some(_) => {
705                self.parse_failed(at.join(key), "expected a string");
706                default.to_string()
707            }
708        }
709    }
710
711    fn u32_or(
712        &mut self,
713        obj: &Map<String, Value>,
714        key: &str,
715        at: &AuthoredPath,
716        default: u32,
717    ) -> u32 {
718        let Some(raw) = obj.get(key) else {
719            return default;
720        };
721        raw.as_u64()
722            .and_then(|n| u32::try_from(n).ok())
723            .unwrap_or_else(|| {
724                self.parse_failed(
725                    at.join(key),
726                    "expected a non-negative integer that fits in 32 bits",
727                );
728                default
729            })
730    }
731
732    /// An enum read from its spelling; absent is the default.
733    fn spelled<T: Spelling>(
734        &mut self,
735        obj: &Map<String, Value>,
736        key: &str,
737        at: &AuthoredPath,
738    ) -> T {
739        let Some(raw) = obj.get(key) else {
740            return T::default();
741        };
742        raw.as_str().and_then(T::from_spelling).unwrap_or_else(|| {
743            let spellings: Vec<String> = T::ALL
744                .iter()
745                .map(|v| format!("\"{}\"", v.spelling()))
746                .collect();
747            self.parse_failed(
748                at.join(key),
749                format!("expected one of {}", spellings.join(", ")),
750            );
751            T::default()
752        })
753    }
754
755    fn tags(&mut self, raw: Option<&Value>) -> Vec<String> {
756        let at = AuthoredPath::root().join("tags");
757        match raw {
758            None => Vec::new(),
759            Some(Value::Array(items)) => items
760                .iter()
761                .enumerate()
762                .filter_map(|(i, item)| match item {
763                    Value::String(tag) => Some(tag.clone()),
764                    _ => {
765                        self.parse_failed(at.join(i), "expected a string");
766                        None
767                    }
768                })
769                .collect(),
770            Some(_) => {
771                self.parse_failed(at, "expected an array of strings");
772                Vec::new()
773            }
774        }
775    }
776
777    /// A timestamp, read as v3's serde read it: chrono's `FromStr`, which
778    /// takes RFC 3339 and its relaxed forms (a space for the `T`, an offset
779    /// without its colon, and chrono's own `Display`, `… UTC`).
780    fn timestamp(&mut self, obj: &Map<String, Value>, key: &str) -> Option<DateTime<Utc>> {
781        match obj.get(key) {
782            None | Some(Value::Null) => None,
783            Some(raw) => {
784                let parsed = raw
785                    .as_str()
786                    .and_then(|text| text.parse::<DateTime<Utc>>().ok());
787                if parsed.is_none() {
788                    self.parse_failed(
789                        AuthoredPath::root().join(key),
790                        "expected a timestamp such as \"2026-01-02T03:04:05Z\"",
791                    );
792                }
793                parsed
794            }
795        }
796    }
797
798    /// `rollout`, then its unknown keys. Absent or `null` is no rollout.
799    fn rollout(&mut self, raw: Option<&Value>, ext: &mut Ext) -> Option<Rollout> {
800        let at = AuthoredPath::root().join("rollout");
801        let obj = match raw {
802            None | Some(Value::Null) => return None,
803            Some(Value::Object(obj)) => obj,
804            Some(_) => {
805                self.parse_failed(at, "expected an object");
806                return None;
807            }
808        };
809        let start = self.bucket(obj, "bucket_start", &at);
810        let end = self.bucket(obj, "bucket_end", &at);
811        self.unknown_keys(obj, &ROLLOUT, &[], &at, "rollout.", ext);
812        let rollout = Rollout::new(start?, end?);
813        if let Err(error) = rollout.check() {
814            self.report(IssueCode::InvalidRollout, at, error.to_string());
815        }
816        Some(rollout)
817    }
818
819    fn bucket(&mut self, obj: &Map<String, Value>, key: &str, at: &AuthoredPath) -> Option<u8> {
820        let Some(raw) = obj.get(key) else {
821            self.parse_failed(at.join(key), format!("missing field `{key}`"));
822            return None;
823        };
824        let bucket = raw.as_u64().and_then(|n| u8::try_from(n).ok());
825        if bucket.is_none() {
826            self.parse_failed(at.join(key), "expected an integer from 0 to 255");
827        }
828        bucket
829    }
830}
831
832/// What `step` read of a group before handing it to `group`.
833struct GroupHead {
834    at: AuthoredPath,
835    id: String,
836    terminal: bool,
837    ext: Ext,
838    /// Whether the group sits under `loop.setup`.
839    setup: bool,
840    /// Its enclosing groups.
841    depth: usize,
842}
843
844impl NodeHeader {
845    fn with_ext(mut self, ext: Ext) -> Self {
846        self.ext = ext.into_map();
847        self
848    }
849}
850
851/// The value as a non-empty string, if it is one.
852fn non_empty_str(value: Option<&Value>) -> Option<&str> {
853    value.and_then(Value::as_str).filter(|s| !s.is_empty())
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use serde_json::json;
860
861    type Row = (IssueCode, Severity, String);
862
863    fn rows(issues: &[Issue]) -> Vec<Row> {
864        issues
865            .iter()
866            .map(|i| (i.code, i.severity, i.path.to_string()))
867            .collect()
868    }
869
870    fn row(code: IssueCode, severity: Severity, path: &str) -> Row {
871        (code, severity, path.to_string())
872    }
873
874    fn leaf(id: &str) -> Value {
875        json!({"id": id, "function": {"name": "f"}})
876    }
877
878    fn steps_at(steps: &Value, root: &str) -> (Option<Vec<StepDef>>, Vec<Issue>) {
879        crate::def::parse_steps(steps, &root.parse().unwrap(), ParseOptions::default())
880    }
881
882    /// `count` groups, each the only member of the one before, around a leaf.
883    fn nested(count: usize) -> Value {
884        let mut node = leaf("leaf");
885        for level in (0..count).rev() {
886            node = json!({"id": format!("g{level}"), "tasks": [node]});
887        }
888        json!([node])
889    }
890
891    #[test]
892    fn parse_steps_reports_under_its_root_with_the_compat_reader_first() {
893        let steps = json!([
894            {"function": {"name": "f"}},
895            {"id": "b", "continue_on_error": true, "function": {"name": "f"}},
896        ]);
897        let (def, issues) = steps_at(&steps, "tasks[2].tasks");
898        assert!(def.is_none());
899        assert_eq!(
900            rows(&issues),
901            [
902                row(
903                    IssueCode::V3Spelling,
904                    Severity::Advisory,
905                    "tasks[2].tasks[1].continue_on_error"
906                ),
907                row(
908                    IssueCode::MissingStepId,
909                    Severity::Rejected,
910                    "tasks[2].tasks[0].id"
911                ),
912            ]
913        );
914        assert_eq!(issues[0].step_kind, Some(StepKind::Task));
915        assert_eq!(issues[0].step_id.as_deref(), Some("b"));
916
917        // With the reader off, the v3 key is a Rejected unknown key.
918        let (def, issues) = parse_steps(
919            &steps,
920            &"tasks[2].tasks".parse().unwrap(),
921            ParseOptions::default().compat(false),
922        );
923        assert!(def.is_none());
924        assert!(issues.iter().all(|i| i.code != IssueCode::V3Spelling));
925        assert!(issues.iter().any(|i| i.severity == Severity::Rejected
926            && i.path.to_string() == "tasks[2].tasks[1].continue_on_error"));
927    }
928
929    #[test]
930    fn a_fragment_sits_only_at_a_tasks_list_or_loop_setup() {
931        for root in [
932            "tasks",
933            "tasks[0].tasks",
934            "tasks[0].tasks[1].tasks",
935            "loop.setup",
936            "loop.setup[0].tasks",
937        ] {
938            let root: crate::base::AuthoredPath = root.parse().unwrap();
939            assert!(fragment_place(&root).is_some(), "{root}");
940        }
941        for root in [
942            "data.tasks",
943            "loop.tasks",
944            "loop.setup[0]",
945            "tasks[0]",
946            "tasks[0].data.tasks",
947        ] {
948            let root: crate::base::AuthoredPath = root.parse().unwrap();
949            assert!(fragment_place(&root).is_none(), "{root}");
950        }
951        assert!(fragment_place(&crate::base::AuthoredPath::root()).is_none());
952    }
953
954    #[test]
955    fn a_null_condition_is_kept_falsy_with_an_advisory() {
956        let steps = json!([{"id": "t", "function": {"name": "f"}, "condition": null}]);
957        let (def, issues) = steps_at(&steps, "tasks");
958        let def = def.expect("a step");
959        assert_eq!(
960            rows(&issues),
961            [row(
962                IssueCode::NullCondition,
963                Severity::Advisory,
964                "tasks[0].condition"
965            )]
966        );
967        let condition = match &def[0] {
968            StepDef::Task(task) => task.condition(),
969            StepDef::Group(group) => group.condition(),
970        };
971        assert!(matches!(condition, Some(Logic(Value::Null))));
972    }
973
974    #[test]
975    fn parse_steps_under_loop_setup_makes_setup_steps() {
976        let steps = json!([{"id": "g", "tasks": [{"id": "s"}]}]);
977        let (def, issues) = steps_at(&steps, "loop.setup");
978        assert!(def.is_none());
979        assert_eq!(
980            rows(&issues),
981            [row(
982                IssueCode::MissingFunction,
983                Severity::Rejected,
984                "loop.setup[0].tasks[0].function"
985            )]
986        );
987        assert_eq!(issues[0].step_kind, Some(StepKind::Setup));
988        assert_eq!(issues[0].step_id.as_deref(), Some("s"));
989    }
990
991    #[test]
992    fn parse_steps_checks_duplicate_ids_within_the_fragment() {
993        let steps = json!([leaf("a"), {"id": "g", "tasks": [leaf("a")]}]);
994        let (def, issues) = steps_at(&steps, "tasks");
995        assert!(def.is_none());
996        assert_eq!(
997            rows(&issues),
998            [row(
999                IssueCode::DuplicateStepId,
1000                Severity::Rejected,
1001                "tasks[1].tasks[0].id"
1002            )]
1003        );
1004    }
1005
1006    #[test]
1007    fn parse_steps_counts_the_roots_groups_into_the_depth() {
1008        let deepest = |root: &str, count: usize| {
1009            let mut path = root.to_string();
1010            for _ in 1..count {
1011                path.push_str("[0].tasks");
1012            }
1013            path.push_str("[0]");
1014            path
1015        };
1016        // Eight groups deep is accepted under `tasks`, and too deep one
1017        // group further in.
1018        let eight = nested(MAX_GROUP_DEPTH);
1019        let (def, issues) = steps_at(&eight, "tasks");
1020        assert!(def.is_some() && issues.is_empty(), "{issues:?}");
1021        let (def, issues) = steps_at(&eight, "tasks[0].tasks");
1022        assert!(def.is_none());
1023        assert_eq!(
1024            rows(&issues),
1025            [row(
1026                IssueCode::GroupTooDeep,
1027                Severity::Rejected,
1028                &deepest("tasks[0].tasks", MAX_GROUP_DEPTH)
1029            )]
1030        );
1031        // Under `tasks`, the same report takes one group more.
1032        let (_, issues) = steps_at(&nested(MAX_GROUP_DEPTH + 1), "tasks");
1033        assert_eq!(
1034            rows(&issues),
1035            [row(
1036                IssueCode::GroupTooDeep,
1037                Severity::Rejected,
1038                &deepest("tasks", MAX_GROUP_DEPTH + 1)
1039            )]
1040        );
1041        // `loop.setup` is the top of the setup list, as `tasks` is of the body.
1042        let (def, issues) = steps_at(&eight, "loop.setup");
1043        assert!(def.is_some() && issues.is_empty(), "{issues:?}");
1044        let (_, issues) = steps_at(&eight, "loop.setup[0].tasks");
1045        assert_eq!(issues.len(), 1);
1046        assert_eq!(issues[0].code, IssueCode::GroupTooDeep);
1047    }
1048
1049    #[test]
1050    fn parse_steps_refuses_a_fragment_that_is_not_an_array() {
1051        for steps in [json!({"id": "a"}), json!(null), json!("tasks")] {
1052            let (def, issues) = steps_at(&steps, "tasks[2].tasks");
1053            assert!(def.is_none());
1054            assert_eq!(
1055                rows(&issues),
1056                [row(
1057                    IssueCode::ParseFailed,
1058                    Severity::Rejected,
1059                    "tasks[2].tasks"
1060                )]
1061            );
1062        }
1063        let (def, issues) = steps_at(&json!([]), "tasks");
1064        assert_eq!(def, Some(Vec::new()));
1065        assert!(issues.is_empty());
1066    }
1067
1068    #[test]
1069    fn parse_steps_reads_steps_as_parse_does() {
1070        // A v3 flag, a stray publish key the reader moves into `ext`, an
1071        // unknown key the walk moves, and a group's `continue_on_error`.
1072        let steps = json!([
1073            {"id": "a", "halt_on": "failure", "function": {"name": "validate"}},
1074            {"id": "p", "colour": 1, "function": {"name": "publish_json",
1075                "input": {"source": "x", "target": "y", "pretty": true, "indent": 2}}},
1076            {"id": "g", "continue_on_error": true, "tasks": [leaf("t")]},
1077        ]);
1078        let parsed = parse(
1079            &json!({"id": "w", "name": "n", "tasks": steps}),
1080            ParseOptions::default(),
1081        );
1082        let (def, issues) = steps_at(&steps, "tasks");
1083        assert_eq!(issues, parsed.issues);
1084        assert_eq!(def, parsed.def.map(|def| def.tasks));
1085        let Some(StepDef::Task(publish)) = def.as_ref().map(|steps| &steps[1]) else {
1086            panic!("expected the publish task: {def:?}");
1087        };
1088        assert!(publish.header.ext.contains_key("function.input.indent"));
1089        assert!(publish.header.ext.contains_key("colour"));
1090    }
1091}