1use crate::engine::error::{DataflowError, Result};
2use crate::engine::functions::{ConnectorName, FunctionConfig};
3use crate::engine::task::Task;
4use chrono::{DateTime, Utc};
5use datalogic_rs::Logic;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::fs;
9use std::path::Path;
10use std::sync::Arc;
11
12pub use crate::engine::rollout::{Rollout, RolloutError};
13
14#[derive(Clone, Debug, Deserialize)]
72#[non_exhaustive]
73pub struct LoopConfig {
74 #[serde(default)]
85 pub counter: Option<String>,
86
87 #[serde(default)]
89 pub init: i64,
90
91 #[serde(default = "default_increment")]
94 pub increment: i64,
95
96 pub max: i64,
101
102 #[serde(default, deserialize_with = "crate::engine::steps::flatten")]
111 pub setup: Vec<Task>,
112
113 #[serde(default, deserialize_with = "present_value")]
126 pub over: Option<Value>,
127
128 #[serde(default, rename = "as")]
132 pub item: Option<String>,
133
134 #[serde(default)]
139 pub scratch: Option<String>,
140
141 #[doc(hidden)]
145 #[serde(skip)]
146 pub counter_parts: Arc<[Arc<str>]>,
147
148 #[doc(hidden)]
150 #[serde(skip)]
151 pub item_parts: Arc<[Arc<str>]>,
152
153 #[doc(hidden)]
156 #[serde(skip)]
157 pub scratch_parts: Arc<[Arc<str>]>,
158
159 #[doc(hidden)]
162 #[serde(skip)]
163 pub compiled_over: Option<Arc<Logic>>,
164}
165
166fn default_increment() -> i64 {
167 1
168}
169
170fn present_value<'de, D>(deserializer: D) -> std::result::Result<Option<Value>, D::Error>
173where
174 D: serde::Deserializer<'de>,
175{
176 Value::deserialize(deserializer).map(Some)
177}
178
179fn no_parts() -> Arc<[Arc<str>]> {
182 Arc::from([] as [Arc<str>; 0])
183}
184
185pub(crate) fn is_slot_path(path: &str) -> bool {
188 !path.is_empty() && !path.split('.').any(str::is_empty)
189}
190
191pub(crate) fn slots_overlap(a: &str, b: &str) -> bool {
195 fn within(outer: &str, inner: &str) -> bool {
196 inner
197 .strip_prefix(outer)
198 .is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
199 }
200 within(a, b) || within(b, a)
201}
202
203pub(crate) fn over_literal_problem(over: &Value) -> Option<String> {
209 let kind = match over {
210 Value::Object(_) | Value::Array(_) => return None,
211 Value::Null => "null",
212 Value::Bool(_) => "a boolean",
213 Value::Number(_) => "a number",
214 Value::String(_) => "a string",
215 };
216 Some(format!(
217 "loop over must be a JSONLogic expression or an array literal — \
218 {kind} can never evaluate to an array"
219 ))
220}
221
222impl LoopConfig {
223 pub fn bounded(max: i64) -> Self {
234 Self {
235 counter: None,
236 init: 0,
237 increment: default_increment(),
238 max,
239 setup: Vec::new(),
240 over: None,
241 item: None,
242 scratch: None,
243 counter_parts: no_parts(),
244 item_parts: no_parts(),
245 scratch_parts: no_parts(),
246 compiled_over: None,
247 }
248 }
249
250 fn validate(&self, workflow_id: &str) -> Result<()> {
254 if self.increment < 1 {
255 return Err(DataflowError::Workflow(format!(
256 "Workflow {workflow_id}: loop increment must be >= 1, got {} \
257 (a non-advancing counter would never reach max)",
258 self.increment
259 )));
260 }
261 if self.max <= self.init {
262 return Err(DataflowError::Workflow(format!(
263 "Workflow {workflow_id}: loop max ({}) must be greater than init ({}) — \
264 the bound is half-open, so this could never run a sweep",
265 self.max, self.init
266 )));
267 }
268 for (field, slot) in [
269 ("counter", &self.counter),
270 ("as", &self.item),
271 ("scratch", &self.scratch),
272 ] {
273 if let Some(path) = slot
274 && !is_slot_path(path)
275 {
276 return Err(DataflowError::Workflow(format!(
277 "Workflow {workflow_id}: loop {field} must be a non-empty \
278 temp_data field path, got {path:?}"
279 )));
280 }
281 }
282 if self.item.is_some() && self.over.is_none() {
283 return Err(DataflowError::Workflow(format!(
284 "Workflow {workflow_id}: loop `as` names a slot for the current element, \
285 but there is no `over` to take elements from"
286 )));
287 }
288 if let Some(over) = &self.over {
289 if let Some(problem) = over_literal_problem(over) {
290 return Err(DataflowError::Workflow(format!(
291 "Workflow {workflow_id}: {problem}"
292 )));
293 }
294 if self.init < 0 {
295 return Err(DataflowError::Workflow(format!(
296 "Workflow {workflow_id}: loop init must be >= 0 when over is set — \
297 the counter indexes the array, got {}",
298 self.init
299 )));
300 }
301 }
302 let slots = [
306 ("counter", self.counter.as_deref()),
307 ("as", self.item.as_deref()),
308 ("scratch", self.scratch.as_deref()),
309 ];
310 for (i, (name_a, a)) in slots.iter().enumerate() {
311 for (name_b, b) in &slots[i + 1..] {
312 if let (Some(a), Some(b)) = (a, b)
313 && slots_overlap(a, b)
314 {
315 return Err(DataflowError::Workflow(format!(
316 "Workflow {workflow_id}: loop {name_a} ({a:?}) and {name_b} ({b:?}) \
317 overlap — each engine-owned slot must be its own temp_data path"
318 )));
319 }
320 }
321 }
322 Ok(())
323 }
324
325 #[doc(hidden)]
332 pub fn precompute_paths(&mut self) {
333 fn parts(name: Option<&str>) -> Arc<[Arc<str>]> {
334 match name {
335 Some(name) => crate::engine::utils::compute_path_parts("temp_data", name),
336 None => no_parts(),
337 }
338 }
339 self.counter_parts = parts(self.counter.as_deref());
340 self.item_parts = parts(self.item.as_deref());
341 self.scratch_parts = parts(self.scratch.as_deref());
342 }
343}
344
345#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "lowercase")]
348pub enum WorkflowStatus {
349 #[default]
350 Active,
351 Paused,
352 Archived,
353}
354
355#[derive(Clone, Debug, Deserialize)]
368#[non_exhaustive]
369pub struct Workflow {
370 pub id: String,
371 #[doc(hidden)]
376 #[serde(skip)]
377 pub id_arc: Arc<str>,
378 pub name: String,
379 #[serde(default)]
380 pub priority: u32,
381 pub description: Option<String>,
382 #[serde(default = "crate::engine::utils::default_condition")]
383 pub condition: Value,
384 #[doc(hidden)]
388 #[serde(skip)]
389 pub compiled_condition: Option<Arc<Logic>>,
390 #[doc(hidden)]
396 #[serde(skip, default)]
397 pub fully_sync: bool,
398 #[serde(deserialize_with = "crate::engine::steps::flatten")]
407 pub tasks: Vec<Task>,
408 #[serde(default)]
409 pub continue_on_error: bool,
410 #[serde(default = "default_channel")]
412 pub channel: String,
413 #[serde(default = "default_version")]
415 pub version: u32,
416 #[serde(default)]
418 pub status: WorkflowStatus,
419 #[serde(default)]
426 pub rollout: Option<Rollout>,
427 #[serde(default, rename = "loop")]
433 pub loop_config: Option<LoopConfig>,
434 #[serde(default)]
436 pub tags: Vec<String>,
437 #[serde(default)]
439 pub created_at: Option<DateTime<Utc>>,
440 #[serde(default)]
442 pub updated_at: Option<DateTime<Utc>>,
443}
444
445fn default_channel() -> String {
446 "default".to_string()
447}
448
449fn default_version() -> u32 {
450 1
451}
452
453impl Default for Workflow {
454 fn default() -> Self {
455 Self::new()
456 }
457}
458
459impl Workflow {
460 pub fn new() -> Self {
461 Self {
462 id: String::new(),
463 id_arc: Arc::from(""),
464 name: String::new(),
465 priority: 0,
466 description: None,
467 condition: Value::Bool(true),
468 compiled_condition: None,
469 fully_sync: false,
470 tasks: Vec::new(),
471 continue_on_error: false,
472 channel: default_channel(),
473 version: 1,
474 status: WorkflowStatus::Active,
475 rollout: None,
476 loop_config: None,
477 tags: Vec::new(),
478 created_at: None,
479 updated_at: None,
480 }
481 }
482
483 pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
494 Self {
495 id: id.to_string(),
496 id_arc: Arc::from(id),
497 name: name.to_string(),
498 priority: 0,
499 description: None,
500 condition,
501 compiled_condition: None,
502 fully_sync: false,
503 tasks,
504 continue_on_error: false,
505 channel: default_channel(),
506 version: 1,
507 status: WorkflowStatus::Active,
508 rollout: None,
509 loop_config: None,
510 tags: Vec::new(),
511 created_at: None,
512 updated_at: None,
513 }
514 }
515
516 pub fn from_json(json_str: &str) -> Result<Self> {
518 serde_json::from_str(json_str).map_err(DataflowError::from_serde)
519 }
520
521 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
523 let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
524
525 Self::from_json(&json_str)
526 }
527
528 pub fn validate(&self) -> Result<()> {
530 if self.id.is_empty() {
532 return Err(DataflowError::Workflow(
533 "Workflow id cannot be empty".to_string(),
534 ));
535 }
536
537 if self.name.is_empty() {
538 return Err(DataflowError::Workflow(
539 "Workflow name cannot be empty".to_string(),
540 ));
541 }
542
543 if self.tasks.is_empty() {
545 return Err(DataflowError::Workflow(
546 "Workflow must have at least one task".to_string(),
547 ));
548 }
549
550 let mut step_ids = std::collections::HashSet::new();
554 for task in self.all_tasks() {
555 for group in &task.group_starts {
556 if !step_ids.insert(group.id.as_str()) {
557 return Err(DataflowError::Workflow(format!(
558 "Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
559 group.id
560 )));
561 }
562 }
563 if !step_ids.insert(task.id.as_str()) {
564 return Err(DataflowError::Workflow(format!(
565 "Duplicate task ID '{}' in workflow",
566 task.id
567 )));
568 }
569 }
570
571 for task in self.all_tasks() {
574 if let Some(for_each) = &task.for_each
575 && let Some(problem) = for_each.problem(&task.function)
576 {
577 return Err(DataflowError::Workflow(format!(
578 "Task '{}': {}",
579 task.id, problem.message
580 )));
581 }
582 }
583
584 if let Some(loop_config) = &self.loop_config {
588 loop_config.validate(&self.id)?;
589 }
590
591 Ok(())
592 }
593}
594
595#[derive(Debug, Clone, Copy)]
604pub struct ConnectorRef<'a> {
605 pub workflow_id: &'a str,
607 pub task_id: &'a str,
609 pub function: &'a str,
611 pub connector: ConnectorName<'a>,
614 pub config: &'a FunctionConfig,
616}
617
618impl Workflow {
619 pub fn all_tasks(&self) -> impl Iterator<Item = &Task> {
627 self.loop_config
628 .iter()
629 .flat_map(|l| l.setup.iter())
630 .chain(self.tasks.iter())
631 }
632
633 pub(crate) fn all_tasks_mut(&mut self) -> impl Iterator<Item = &mut Task> {
635 self.loop_config
636 .iter_mut()
637 .flat_map(|l| l.setup.iter_mut())
638 .chain(self.tasks.iter_mut())
639 }
640
641 pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
656 self.all_tasks().filter_map(move |task| {
659 task.function.connector().map(|connector| ConnectorRef {
660 workflow_id: &self.id,
661 task_id: &task.id,
662 function: task.function.function_name(),
663 connector,
664 config: &task.function,
665 })
666 })
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 fn wf(tasks_json: &str) -> Workflow {
675 Workflow::from_json(&format!(
676 r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
677 "tasks": [{tasks_json}] }}"#
678 ))
679 .expect("workflow should parse")
680 }
681
682 const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
683 "name": "http_call", "input": { "connector": "user_service" } } }"#;
684 const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
685 "name": "publish_kafka",
686 "input": { "connector": "events", "topic": "t" } } }"#;
687 const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
688 "name": "map", "input": { "mappings": [] } } }"#;
689 const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
690 "name": "log", "input": { "message": "hi" } } }"#;
691
692 #[test]
693 fn connector_refs_yields_only_connector_tasks_in_task_order() {
694 let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
695 let refs: Vec<_> = workflow.connector_refs().collect();
696
697 assert_eq!(refs.len(), 2);
698 assert_eq!(refs[0].task_id, "call");
699 assert_eq!(refs[0].function, "http_call");
700 assert_eq!(refs[0].connector.as_static(), Some("user_service"));
701 assert_eq!(refs[1].task_id, "pub");
702 assert_eq!(refs[1].function, "publish_kafka");
703 assert_eq!(refs[1].connector.as_static(), Some("events"));
704 }
705
706 #[test]
707 fn connector_refs_carries_the_owning_workflow_id() {
708 let workflow = wf(HTTP);
709 assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
710
711 let empty = Workflow::new();
713 assert_eq!(empty.id, "");
714 assert_eq!(empty.connector_refs().count(), 0);
715 }
716
717 #[test]
718 fn connector_refs_is_empty_for_no_tasks() {
719 assert_eq!(Workflow::new().connector_refs().count(), 0);
722 }
723
724 #[test]
725 fn connector_refs_does_not_deduplicate() {
726 let a = r#"{ "id": "a", "name": "a", "function": {
727 "name": "http_call", "input": { "connector": "same" } } }"#;
728 let b = r#"{ "id": "b", "name": "b", "function": {
729 "name": "enrich",
730 "input": { "connector": "same", "merge_path": "data.out" } } }"#;
731 let workflow = wf(&format!("{a},{b}"));
732
733 let refs: Vec<_> = workflow.connector_refs().collect();
734 assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
735 assert!(refs.iter().all(|r| r.connector.as_static() == Some("same")));
736 }
737
738 #[test]
739 fn connector_refs_works_on_an_uncompiled_workflow() {
740 let workflow = wf(HTTP);
743 assert!(workflow.compiled_condition.is_none());
744 assert_eq!(workflow.connector_refs().count(), 1);
745 }
746
747 #[test]
748 fn connector_ref_is_copy() {
749 let workflow = wf(HTTP);
750 let r = workflow.connector_refs().next().unwrap();
751 let copied = r;
752 assert_eq!(r.connector, copied.connector);
754 assert_eq!(r.task_id, copied.task_id);
755 }
756
757 #[test]
758 fn connector_ref_config_supports_a_cross_field_rule() {
759 let custom = r#"{ "id": "db", "name": "db", "function": {
762 "name": "pg_query",
763 "input": { "connector": "pg_main", "database": "orders" } } }"#;
764 let workflow = wf(custom);
765
766 let r = workflow.connector_refs().next().expect("custom connector");
767 assert_eq!(r.connector.as_static(), Some("pg_main"));
768 match r.config {
769 FunctionConfig::Custom { input, .. } => {
770 assert_eq!(
771 input.get("database").and_then(|v| v.as_str()),
772 Some("orders")
773 );
774 }
775 other => panic!("expected Custom, got {other:?}"),
776 }
777 }
778
779 #[test]
780 fn rollout_defaults_to_none_on_every_construction_path() {
781 assert_eq!(Workflow::new().rollout, None);
782 assert_eq!(Workflow::default().rollout, None);
783 assert_eq!(
784 Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
785 None
786 );
787 assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
788 }
789
790 fn loop_wf(loop_json: &str) -> Result<Workflow> {
796 let workflow = Workflow::from_json(&format!(
797 r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
798 ))?;
799 workflow.validate()?;
800 Ok(workflow)
801 }
802
803 #[test]
804 fn loop_config_defaults_init_zero_increment_one() {
805 let cfg = loop_wf(r#"{"max": 5}"#)
806 .expect("valid loop")
807 .loop_config
808 .expect("loop config present");
809 assert_eq!(cfg.init, 0);
810 assert_eq!(cfg.increment, 1);
811 assert_eq!(cfg.max, 5);
812 assert_eq!(cfg.counter, None);
813 }
814
815 #[test]
816 fn loop_config_is_absent_on_every_construction_path() {
817 assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
818 assert!(Workflow::new().loop_config.is_none());
819 assert!(Workflow::default().loop_config.is_none());
820 assert!(
821 Workflow::rule("r", "r", Value::Bool(true), Vec::new())
822 .loop_config
823 .is_none()
824 );
825 }
826
827 #[test]
828 fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
829 assert!(loop_wf(r#"{"max": 0}"#).is_err());
832 assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
833 assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
834 }
835
836 #[test]
837 fn loop_config_rejects_a_non_advancing_increment() {
838 assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
839 assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
840 }
841
842 #[test]
843 fn loop_config_rejects_an_empty_counter_path() {
844 assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
845 assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
846 assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
847 }
848
849 #[test]
850 fn loop_config_requires_max() {
851 assert!(
854 Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
855 );
856 }
857
858 #[test]
859 fn loop_config_deserializes_every_combination_of_optional_fields() {
860 for (json, counter, init, increment) in [
864 (r#"{"max": 9}"#, None, 0, 1),
865 (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
866 (r#"{"max": 9, "init": 4}"#, None, 4, 1),
867 (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
868 (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
869 (
870 r#"{"max": 9, "counter": "i", "increment": 3}"#,
871 Some("i"),
872 0,
873 3,
874 ),
875 (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
876 (
877 r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
878 Some("i"),
879 4,
880 3,
881 ),
882 ] {
883 let cfg = loop_wf(json)
884 .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
885 .loop_config
886 .expect("loop config present");
887 assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
888 assert_eq!(cfg.init, init, "init for {json}");
889 assert_eq!(cfg.increment, increment, "increment for {json}");
890 assert_eq!(cfg.max, 9, "max for {json}");
891 }
892 }
893
894 #[test]
895 fn loop_config_validation_matrix_over_init_increment_and_max() {
896 for (init, increment, max, valid) in [
900 (0_i64, 1_i64, 1_i64, true),
902 (0, 1, 100, true),
903 (0, 7, 3, true), (5, 1, 6, true),
905 (-5, 1, 0, true),
907 (-5, 2, -4, true),
908 (-1, 1, 1, true),
909 (0, 1, 0, false),
911 (5, 1, 5, false),
912 (5, 1, 4, false),
913 (0, 1, -1, false),
914 (-5, 1, -5, false),
915 (0, 0, 10, false),
917 (0, -1, 10, false),
918 (0, -100, 10, false),
919 ] {
920 let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
921 assert_eq!(
922 loop_wf(&json).is_ok(),
923 valid,
924 "init={init} increment={increment} max={max} should be {}",
925 if valid { "accepted" } else { "rejected" }
926 );
927 }
928 }
929
930 #[test]
931 fn loop_config_counter_path_matrix() {
932 for (counter, valid) in [
935 ("i", true),
936 ("index", true),
937 ("cursor.index", true),
938 ("a.b.c.d", true),
939 ("#7", true), ("", false),
941 (".", false),
942 ("a.", false),
943 (".a", false),
944 ("a..b", false),
945 ] {
946 let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
947 assert_eq!(
948 loop_wf(&json).is_ok(),
949 valid,
950 "counter {counter:?} should be {}",
951 if valid { "accepted" } else { "rejected" }
952 );
953 }
954 }
955
956 #[test]
957 fn precompute_counter_path_matrix() {
958 for (counter, expected) in [
959 ("i", vec!["temp_data", "i"]),
960 ("cursor.index", vec!["temp_data", "cursor", "index"]),
961 ("a.b.c", vec!["temp_data", "a", "b", "c"]),
962 ("#7", vec!["temp_data", "#7"]),
965 ] {
966 let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
967 .expect("valid loop")
968 .loop_config
969 .expect("loop config present");
970 cfg.precompute_paths();
971 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
972 assert_eq!(parts, expected, "for counter {counter:?}");
973 }
974 }
975
976 #[test]
977 fn precompute_counter_path_is_idempotent() {
978 let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
981 .expect("valid loop")
982 .loop_config
983 .expect("loop config present");
984 cfg.precompute_paths();
985 let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
986 cfg.precompute_paths();
987 assert_eq!(cfg.counter_parts.to_vec(), first);
988 }
989
990 #[test]
991 fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
992 for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
993 assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
994 }
995 }
996
997 #[test]
998 fn an_explicit_null_loop_means_no_loop() {
999 let workflow = loop_wf("null").expect("explicit null should be accepted");
1003 assert!(workflow.loop_config.is_none());
1004 }
1005
1006 #[test]
1007 fn a_workflow_with_a_loop_still_validates_its_other_rules() {
1008 let duplicate_tasks = Workflow::from_json(
1011 r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
1012 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
1013 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1014 )
1015 .expect("should parse");
1016 assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
1017
1018 let no_tasks =
1019 Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
1020 .expect("should parse");
1021 assert!(no_tasks.validate().is_err(), "empty task list");
1022 }
1023
1024 #[test]
1025 fn loop_config_coexists_with_every_other_workflow_field() {
1026 let workflow = Workflow::from_json(&format!(
1029 r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
1030 "condition": {{"==": [1, 1]}},
1031 "loop": {{"counter": "i", "max": 5}},
1032 "continue_on_error": true, "channel": "c", "version": 3,
1033 "status": "paused",
1034 "rollout": {{"bucket_start": 0, "bucket_end": 50}},
1035 "tags": ["x"], "tasks": [{MAP}] }}"#
1036 ))
1037 .expect("should parse");
1038 workflow.validate().expect("should validate");
1039
1040 assert_eq!(workflow.priority, 7);
1041 assert_eq!(workflow.channel, "c");
1042 assert_eq!(workflow.version, 3);
1043 assert_eq!(workflow.status, WorkflowStatus::Paused);
1044 assert!(workflow.continue_on_error);
1045 assert_eq!(
1046 workflow.rollout,
1047 Some(Rollout {
1048 bucket_start: 0,
1049 bucket_end: 50
1050 })
1051 );
1052 assert_eq!(workflow.tags, ["x"]);
1053 assert_eq!(
1054 workflow
1055 .loop_config
1056 .expect("loop present")
1057 .counter
1058 .as_deref(),
1059 Some("i")
1060 );
1061 }
1062
1063 #[test]
1064 fn loop_config_accepts_a_valid_counter() {
1065 let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
1066 .expect("valid loop")
1067 .loop_config
1068 .expect("loop config present");
1069 assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
1070 }
1071
1072 #[test]
1073 fn precompute_counter_path_prefixes_temp_data() {
1074 let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
1075 .expect("valid loop")
1076 .loop_config
1077 .expect("loop config present");
1078 assert!(
1079 cfg.counter_parts.is_empty(),
1080 "uncompiled workflows start with no pre-split path"
1081 );
1082
1083 cfg.precompute_paths();
1084
1085 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
1086 assert_eq!(parts, ["temp_data", "cursor", "index"]);
1087 }
1088
1089 #[test]
1090 fn precompute_counter_path_is_empty_without_a_counter_name() {
1091 let mut cfg = loop_wf(r#"{"max": 5}"#)
1092 .expect("valid loop")
1093 .loop_config
1094 .expect("loop config present");
1095 cfg.precompute_paths();
1096 assert!(cfg.counter_parts.is_empty());
1097 }
1098
1099 #[test]
1100 fn loop_config_parses_setup_over_as_and_scratch() {
1101 let cfg = loop_wf(
1102 r#"{"counter": "i", "max": 64, "as": "item", "scratch": "it",
1103 "over": {"var": "temp_data.batch.items"},
1104 "setup": [
1105 {"id": "claim", "name": "claim", "function": {"name": "map", "input": {"mappings": []}}},
1106 {"id": "read", "condition": true, "tasks": [
1107 {"id": "batch", "name": "batch", "function": {"name": "map", "input": {"mappings": []}}}
1108 ]}
1109 ]}"#,
1110 )
1111 .expect("valid loop")
1112 .loop_config
1113 .expect("loop config present");
1114 let setup_ids: Vec<&str> = cfg.setup.iter().map(|t| t.id.as_str()).collect();
1115 assert_eq!(
1116 setup_ids,
1117 ["claim", "batch"],
1118 "setup is flattened like tasks"
1119 );
1120 assert_eq!(
1121 cfg.setup[1].group_starts.len(),
1122 1,
1123 "the group span is recorded"
1124 );
1125 assert_eq!(cfg.item.as_deref(), Some("item"));
1126 assert_eq!(cfg.scratch.as_deref(), Some("it"));
1127 assert!(cfg.over.is_some());
1128 assert!(
1129 cfg.compiled_over.is_none(),
1130 "uncompiled until the engine builds"
1131 );
1132 }
1133
1134 #[test]
1135 fn a_counter_only_loop_has_no_setup_over_as_or_scratch() {
1136 let cfg = loop_wf(r#"{"max": 5}"#).unwrap().loop_config.unwrap();
1137 assert!(cfg.setup.is_empty());
1138 assert!(cfg.over.is_none());
1139 assert!(cfg.item.is_none());
1140 assert!(cfg.scratch.is_none());
1141
1142 let bounded = LoopConfig::bounded(5);
1143 assert_eq!(
1144 (bounded.init, bounded.increment, bounded.max),
1145 (0, 1, 5),
1146 "bounded() lands on the serde defaults"
1147 );
1148 assert!(bounded.setup.is_empty() && bounded.over.is_none());
1149 assert!(bounded.counter.is_none() && bounded.item.is_none() && bounded.scratch.is_none());
1150 }
1151
1152 #[test]
1153 fn loop_config_rejects_as_without_over() {
1154 let err = loop_wf(r#"{"max": 5, "as": "item"}"#).expect_err("as needs over");
1155 assert!(err.to_string().contains("no `over`"), "got: {err}");
1156 assert!(loop_wf(r#"{"max": 5, "over": {"var": "data.items"}, "as": "item"}"#).is_ok());
1157 assert!(
1158 loop_wf(r#"{"max": 5, "over": {"var": "data.items"}}"#).is_ok(),
1159 "as is optional"
1160 );
1161 }
1162
1163 #[test]
1164 fn loop_config_rejects_a_scalar_over_literal() {
1165 for over in [r#""data.items""#, "5", "true", "null"] {
1166 let json = format!(r#"{{"max": 5, "over": {over}}}"#);
1167 assert!(loop_wf(&json).is_err(), "{json} must be rejected");
1168 }
1169 for over in [r#"{"var": "data.items"}"#, "[1, 2, 3]", "[]"] {
1170 let json = format!(r#"{{"max": 5, "over": {over}}}"#);
1171 assert!(
1172 loop_wf(&json).is_ok(),
1173 "{json} is an expression or an array literal"
1174 );
1175 }
1176 }
1177
1178 #[test]
1179 fn loop_config_rejects_a_negative_init_with_over() {
1180 assert!(loop_wf(r#"{"init": -1, "max": 5, "over": []}"#).is_err());
1181 assert!(
1182 loop_wf(r#"{"init": -1, "max": 5}"#).is_ok(),
1183 "fine without over, as today"
1184 );
1185 assert!(
1186 loop_wf(r#"{"init": 2, "max": 5, "over": []}"#).is_ok(),
1187 "an offset is fine"
1188 );
1189 }
1190
1191 #[test]
1192 fn loop_config_slot_path_and_collision_matrix() {
1193 for (counter, item, scratch, valid) in [
1194 ("i", "item", "it", true),
1195 ("i", "it.item", "scratch", true),
1196 ("a.b", "a.c", "a.d", true),
1197 ("i", "i", "it", false), ("i", "item", "item", false), ("it", "it.item", "x", false), ("i", "item", "item.x", false), ("it.x", "item", "it", false), ] {
1203 let json = format!(
1204 r#"{{"max": 5, "over": [], "counter": "{counter}", "as": "{item}", "scratch": "{scratch}"}}"#
1205 );
1206 assert_eq!(loop_wf(&json).is_ok(), valid, "{json}");
1207 }
1208 for (field, path) in [
1209 ("as", ""),
1210 ("as", "a..b"),
1211 ("scratch", "."),
1212 ("scratch", "a."),
1213 ] {
1214 let json = format!(r#"{{"max": 5, "over": [], "{field}": "{path}"}}"#);
1215 assert!(loop_wf(&json).is_err(), "{json} is a malformed slot path");
1216 }
1217 }
1218
1219 #[test]
1220 fn slots_overlap_is_segment_aware() {
1221 assert!(slots_overlap("it", "it"));
1222 assert!(slots_overlap("it", "it.item"));
1223 assert!(slots_overlap("it.item", "it"));
1224 assert!(
1225 !slots_overlap("it", "item"),
1226 "a shared prefix is not nesting"
1227 );
1228 assert!(!slots_overlap("a.b", "a.c"));
1229 }
1230
1231 #[test]
1232 fn a_setup_step_sharing_an_id_with_a_body_task_is_rejected() {
1233 let workflow = Workflow::from_json(&format!(
1234 r#"{{ "id": "w", "name": "w",
1235 "loop": {{"max": 5, "setup": [{MAP}]}},
1236 "tasks": [{MAP}] }}"#
1237 ))
1238 .expect("parses");
1239 let err = workflow
1240 .validate()
1241 .expect_err("setup and body share one id namespace");
1242 assert!(err.to_string().contains("Duplicate"), "got: {err}");
1243 }
1244
1245 #[test]
1246 fn a_setup_group_carrying_halt_on_is_refused_at_parse() {
1247 let err = Workflow::from_json(&format!(
1248 r#"{{ "id": "w", "name": "w",
1249 "loop": {{"max": 5, "setup": [
1250 {{"id": "g", "halt_on": "failure", "tasks": [{MAP}]}}]}},
1251 "tasks": [{LOG}] }}"#
1252 ))
1253 .expect_err("the step grammar applies to setup");
1254 assert!(err.to_string().contains("halt_on"), "got: {err}");
1255 }
1256
1257 #[test]
1258 fn all_tasks_yields_setup_then_body_and_connector_refs_covers_setup() {
1259 let workflow = Workflow::from_json(&format!(
1260 r#"{{ "id": "w", "name": "w",
1261 "loop": {{"max": 5, "setup": [{HTTP}]}},
1262 "tasks": [{MAP}, {KAFKA}] }}"#
1263 ))
1264 .expect("parses");
1265 let ids: Vec<&str> = workflow.all_tasks().map(|t| t.id.as_str()).collect();
1266 assert_eq!(ids, ["call", "m", "pub"]);
1267 let refs: Vec<&str> = workflow.connector_refs().map(|r| r.task_id).collect();
1268 assert_eq!(
1269 refs,
1270 ["call", "pub"],
1271 "the setup connector is reported first"
1272 );
1273 assert_eq!(wf(MAP).all_tasks().count(), 1, "no loop: just the body");
1274 }
1275
1276 #[test]
1277 fn precompute_paths_fills_all_three_slots() {
1278 let mut cfg =
1279 loop_wf(r#"{"max": 5, "counter": "i", "over": [], "as": "it.item", "scratch": "scr"}"#)
1280 .unwrap()
1281 .loop_config
1282 .unwrap();
1283 assert!(cfg.item_parts.is_empty() && cfg.scratch_parts.is_empty());
1284 cfg.precompute_paths();
1285 let parts = |p: &Arc<[Arc<str>]>| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1286 assert_eq!(parts(&cfg.counter_parts), ["temp_data", "i"]);
1287 assert_eq!(parts(&cfg.item_parts), ["temp_data", "it", "item"]);
1288 assert_eq!(parts(&cfg.scratch_parts), ["temp_data", "scr"]);
1289 }
1290
1291 #[test]
1292 fn rollout_deserializes_from_json() {
1293 let workflow = Workflow::from_json(
1294 r#"{ "id": "w", "name": "w", "condition": true,
1295 "rollout": { "bucket_start": 0, "bucket_end": 50 },
1296 "tasks": [] }"#,
1297 )
1298 .unwrap();
1299 assert_eq!(
1300 workflow.rollout,
1301 Some(Rollout {
1302 bucket_start: 0,
1303 bucket_end: 50
1304 })
1305 );
1306 }
1307}