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