1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct ParseOptions {
47 pub compat: bool,
60}
61
62impl Default for ParseOptions {
63 fn default() -> Self {
64 Self { compat: true }
65 }
66}
67
68impl ParseOptions {
69 #[must_use]
71 pub fn compat(mut self, on: bool) -> Self {
72 self.compat = on;
73 self
74 }
75}
76
77#[derive(Clone, Debug, PartialEq)]
79#[non_exhaustive]
80pub struct Parsed {
81 pub def: Option<WorkflowDef>,
83 pub issues: Vec<Issue>,
85}
86
87pub fn parse(json: &Value, opts: ParseOptions) -> Parsed {
96 parse_with_rewrites(json, opts).0
97}
98
99pub 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
109pub(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 let read = reader::read(object.to_mut());
136 (walk(&object, true, read.issues, read.exts), read.rewrites)
137}
138
139pub(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 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
178fn is(seg: &PathSeg, name: &str) -> bool {
180 matches!(seg, PathSeg::Key(key) if &**key == name)
181}
182
183pub(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
209pub(super) fn is_group(obj: &Map<String, Value>) -> bool {
212 obj.contains_key("tasks")
213}
214
215pub(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
227pub(super) fn descends(depth: usize) -> bool {
231 depth < MAX_GROUP_DEPTH
232}
233
234fn 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
252pub(super) struct Walk<'j> {
255 pub(super) compat: bool,
256 issues: Vec<Issue>,
257 seen: HashMap<&'j str, AuthoredPath>,
260 step: Option<(StepKind, Option<&'j str>)>,
262 exts: HashMap<AuthoredPath, Ext>,
264}
265
266impl<'j> Walk<'j> {
267 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 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_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 fn rejected(&self) -> bool {
301 any_rejected(&self.issues)
302 }
303
304 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 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 pub(super) fn parse_failed(&mut self, path: AuthoredPath, message: impl Into<String>) {
331 self.report(IssueCode::ParseFailed, path, message);
332 }
333
334 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 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 let header = self.header(obj, &root);
358
359 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 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 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 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 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 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 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 let terminal = match obj.get("terminal") {
468 None => false,
469 Some(Value::Bool(terminal)) => *terminal,
470 Some(other) => {
471 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 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 let function = self.function(obj, &at, &mut ext);
504 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 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 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 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 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 let header = self.header(obj, &at);
571 let condition = self.condition(obj, &at);
572 self.ext_is_object(obj, &at);
573 self.unknown_keys(obj, &GROUP, &["halt_on", "for_each"], &at, "", &mut ext);
575
576 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 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 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 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 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 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 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 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 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
832struct GroupHead {
834 at: AuthoredPath,
835 id: String,
836 terminal: bool,
837 ext: Ext,
838 setup: bool,
840 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
851fn 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 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 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 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 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 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 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}