1use crate::engine::error::{DataflowError, Result};
2use crate::engine::functions::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
12#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
20pub struct Rollout {
21 pub bucket_start: u8,
23 pub bucket_end: u8,
25}
26
27impl Rollout {
28 #[inline]
33 pub fn accepts(&self, bucket: u8) -> bool {
34 bucket >= self.bucket_start && bucket < self.bucket_end
35 }
36}
37
38#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
65pub struct LoopConfig {
66 #[serde(default)]
77 pub counter: Option<String>,
78
79 #[serde(default)]
81 pub init: i64,
82
83 #[serde(default = "default_increment")]
86 pub increment: i64,
87
88 pub max: i64,
93
94 #[doc(hidden)]
98 #[serde(skip)]
99 pub counter_parts: Arc<[Arc<str>]>,
100}
101
102fn default_increment() -> i64 {
103 1
104}
105
106impl LoopConfig {
107 fn validate(&self, workflow_id: &str) -> Result<()> {
111 if self.increment < 1 {
112 return Err(DataflowError::Workflow(format!(
113 "Workflow {workflow_id}: loop increment must be >= 1, got {} \
114 (a non-advancing counter would never reach max)",
115 self.increment
116 )));
117 }
118 if self.max <= self.init {
119 return Err(DataflowError::Workflow(format!(
120 "Workflow {workflow_id}: loop max ({}) must be greater than init ({}) — \
121 the bound is half-open, so this could never run a sweep",
122 self.max, self.init
123 )));
124 }
125 if let Some(counter) = &self.counter {
126 if counter.is_empty() || counter.split('.').any(str::is_empty) {
127 return Err(DataflowError::Workflow(format!(
128 "Workflow {workflow_id}: loop counter must be a non-empty \
129 temp_data field path, got {counter:?}"
130 )));
131 }
132 }
133 Ok(())
134 }
135
136 #[doc(hidden)]
141 pub fn precompute_counter_path(&mut self) {
142 self.counter_parts = match &self.counter {
143 Some(counter) => crate::engine::utils::compute_path_parts("temp_data", counter),
144 None => Arc::from([] as [Arc<str>; 0]),
145 };
146 }
147}
148
149#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum WorkflowStatus {
153 #[default]
154 Active,
155 Paused,
156 Archived,
157}
158
159#[derive(Clone, Debug, Deserialize)]
163pub struct Workflow {
164 pub id: String,
165 #[doc(hidden)]
170 #[serde(skip)]
171 pub id_arc: Arc<str>,
172 pub name: String,
173 #[serde(default)]
174 pub priority: u32,
175 pub description: Option<String>,
176 #[serde(default = "crate::engine::utils::default_condition")]
177 pub condition: Value,
178 #[doc(hidden)]
182 #[serde(skip)]
183 pub compiled_condition: Option<Arc<Logic>>,
184 #[doc(hidden)]
190 #[serde(skip, default)]
191 pub fully_sync: bool,
192 #[serde(deserialize_with = "crate::engine::task::steps::flatten")]
201 pub tasks: Vec<Task>,
202 #[serde(default)]
203 pub continue_on_error: bool,
204 #[serde(default = "default_channel")]
206 pub channel: String,
207 #[serde(default = "default_version")]
209 pub version: u32,
210 #[serde(default)]
212 pub status: WorkflowStatus,
213 #[serde(default)]
220 pub rollout: Option<Rollout>,
221 #[serde(default, rename = "loop")]
227 pub loop_config: Option<LoopConfig>,
228 #[serde(default)]
230 pub tags: Vec<String>,
231 #[serde(default)]
233 pub created_at: Option<DateTime<Utc>>,
234 #[serde(default)]
236 pub updated_at: Option<DateTime<Utc>>,
237}
238
239fn default_channel() -> String {
240 "default".to_string()
241}
242
243fn default_version() -> u32 {
244 1
245}
246
247impl Default for Workflow {
248 fn default() -> Self {
249 Self::new()
250 }
251}
252
253impl Workflow {
254 pub fn new() -> Self {
255 Workflow {
256 id: String::new(),
257 id_arc: Arc::from(""),
258 name: String::new(),
259 priority: 0,
260 description: None,
261 condition: Value::Bool(true),
262 compiled_condition: None,
263 fully_sync: false,
264 tasks: Vec::new(),
265 continue_on_error: false,
266 channel: default_channel(),
267 version: 1,
268 status: WorkflowStatus::Active,
269 rollout: None,
270 loop_config: None,
271 tags: Vec::new(),
272 created_at: None,
273 updated_at: None,
274 }
275 }
276
277 pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
288 Workflow {
289 id: id.to_string(),
290 id_arc: Arc::from(id),
291 name: name.to_string(),
292 priority: 0,
293 description: None,
294 condition,
295 compiled_condition: None,
296 fully_sync: false,
297 tasks,
298 continue_on_error: false,
299 channel: default_channel(),
300 version: 1,
301 status: WorkflowStatus::Active,
302 rollout: None,
303 loop_config: None,
304 tags: Vec::new(),
305 created_at: None,
306 updated_at: None,
307 }
308 }
309
310 pub fn from_json(json_str: &str) -> Result<Self> {
312 serde_json::from_str(json_str).map_err(DataflowError::from_serde)
313 }
314
315 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
317 let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
318
319 Self::from_json(&json_str)
320 }
321
322 pub fn validate(&self) -> Result<()> {
324 if self.id.is_empty() {
326 return Err(DataflowError::Workflow(
327 "Workflow id cannot be empty".to_string(),
328 ));
329 }
330
331 if self.name.is_empty() {
332 return Err(DataflowError::Workflow(
333 "Workflow name cannot be empty".to_string(),
334 ));
335 }
336
337 if self.tasks.is_empty() {
339 return Err(DataflowError::Workflow(
340 "Workflow must have at least one task".to_string(),
341 ));
342 }
343
344 let mut step_ids = std::collections::HashSet::new();
348 for task in &self.tasks {
349 for group in &task.group_starts {
350 if !step_ids.insert(group.id.as_str()) {
351 return Err(DataflowError::Workflow(format!(
352 "Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
353 group.id
354 )));
355 }
356 }
357 if !step_ids.insert(task.id.as_str()) {
358 return Err(DataflowError::Workflow(format!(
359 "Duplicate task ID '{}' in workflow",
360 task.id
361 )));
362 }
363 }
364
365 if let Some(loop_config) = &self.loop_config {
369 loop_config.validate(&self.id)?;
370 }
371
372 Ok(())
373 }
374}
375
376#[derive(Debug, Clone, Copy)]
385pub struct ConnectorRef<'a> {
386 pub workflow_id: &'a str,
388 pub task_id: &'a str,
390 pub function: &'a str,
392 pub connector: &'a str,
394 pub config: &'a FunctionConfig,
396}
397
398impl Workflow {
399 pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
412 self.tasks.iter().filter_map(move |task| {
415 task.function.connector().map(|connector| ConnectorRef {
416 workflow_id: &self.id,
417 task_id: &task.id,
418 function: task.function.function_name(),
419 connector,
420 config: &task.function,
421 })
422 })
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 fn wf(tasks_json: &str) -> Workflow {
431 Workflow::from_json(&format!(
432 r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
433 "tasks": [{tasks_json}] }}"#
434 ))
435 .expect("workflow should parse")
436 }
437
438 const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
439 "name": "http_call", "input": { "connector": "user_service" } } }"#;
440 const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
441 "name": "publish_kafka",
442 "input": { "connector": "events", "topic": "t" } } }"#;
443 const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
444 "name": "map", "input": { "mappings": [] } } }"#;
445 const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
446 "name": "log", "input": { "message": "hi" } } }"#;
447
448 #[test]
449 fn connector_refs_yields_only_connector_tasks_in_task_order() {
450 let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
451 let refs: Vec<_> = workflow.connector_refs().collect();
452
453 assert_eq!(refs.len(), 2);
454 assert_eq!(refs[0].task_id, "call");
455 assert_eq!(refs[0].function, "http_call");
456 assert_eq!(refs[0].connector, "user_service");
457 assert_eq!(refs[1].task_id, "pub");
458 assert_eq!(refs[1].function, "publish_kafka");
459 assert_eq!(refs[1].connector, "events");
460 }
461
462 #[test]
463 fn connector_refs_carries_the_owning_workflow_id() {
464 let workflow = wf(HTTP);
465 assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
466
467 let empty = Workflow::new();
469 assert_eq!(empty.id, "");
470 assert_eq!(empty.connector_refs().count(), 0);
471 }
472
473 #[test]
474 fn connector_refs_is_empty_for_no_tasks() {
475 assert_eq!(Workflow::new().connector_refs().count(), 0);
478 }
479
480 #[test]
481 fn connector_refs_does_not_deduplicate() {
482 let a = r#"{ "id": "a", "name": "a", "function": {
483 "name": "http_call", "input": { "connector": "same" } } }"#;
484 let b = r#"{ "id": "b", "name": "b", "function": {
485 "name": "enrich",
486 "input": { "connector": "same", "merge_path": "data.out" } } }"#;
487 let workflow = wf(&format!("{a},{b}"));
488
489 let refs: Vec<_> = workflow.connector_refs().collect();
490 assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
491 assert!(refs.iter().all(|r| r.connector == "same"));
492 }
493
494 #[test]
495 fn connector_refs_works_on_an_uncompiled_workflow() {
496 let workflow = wf(HTTP);
499 assert!(workflow.compiled_condition.is_none());
500 assert_eq!(workflow.connector_refs().count(), 1);
501 }
502
503 #[test]
504 fn connector_ref_is_copy() {
505 let workflow = wf(HTTP);
506 let r = workflow.connector_refs().next().unwrap();
507 let copied = r;
508 assert_eq!(r.connector, copied.connector);
510 assert_eq!(r.task_id, copied.task_id);
511 }
512
513 #[test]
514 fn connector_ref_config_supports_a_cross_field_rule() {
515 let custom = r#"{ "id": "db", "name": "db", "function": {
518 "name": "pg_query",
519 "input": { "connector": "pg_main", "database": "orders" } } }"#;
520 let workflow = wf(custom);
521
522 let r = workflow.connector_refs().next().expect("custom connector");
523 assert_eq!(r.connector, "pg_main");
524 match r.config {
525 FunctionConfig::Custom { input, .. } => {
526 assert_eq!(
527 input.get("database").and_then(|v| v.as_str()),
528 Some("orders")
529 );
530 }
531 other => panic!("expected Custom, got {other:?}"),
532 }
533 }
534
535 #[test]
536 fn rollout_accepts_is_a_half_open_range() {
537 let all = Rollout {
538 bucket_start: 0,
539 bucket_end: 100,
540 };
541 assert!(all.accepts(0));
542 assert!(all.accepts(99));
543
544 let lower = Rollout {
545 bucket_start: 0,
546 bucket_end: 50,
547 };
548 assert!(lower.accepts(0));
549 assert!(lower.accepts(49));
550 assert!(!lower.accepts(50), "bucket_end is exclusive");
551 assert!(!lower.accepts(99));
552
553 let upper = Rollout {
555 bucket_start: 50,
556 bucket_end: 100,
557 };
558 assert!(upper.accepts(50), "bucket_start is inclusive");
559 assert!(upper.accepts(99));
560 assert!(!upper.accepts(49));
561
562 for b in 0u8..=99 {
564 assert_ne!(
565 lower.accepts(b),
566 upper.accepts(b),
567 "bucket {b} must be served by exactly one half"
568 );
569 }
570 }
571
572 #[test]
573 fn rollout_empty_and_inverted_ranges_accept_nothing() {
574 let empty = Rollout {
575 bucket_start: 50,
576 bucket_end: 50,
577 };
578 let inverted = Rollout {
579 bucket_start: 60,
580 bucket_end: 20,
581 };
582 for b in 0u8..=99 {
583 assert!(!empty.accepts(b), "empty range accepted {b}");
584 assert!(!inverted.accepts(b), "inverted range accepted {b}");
585 }
586 }
587
588 #[test]
589 fn rollout_end_of_100_is_representable_without_overflow() {
590 let r = Rollout {
592 bucket_start: 99,
593 bucket_end: 100,
594 };
595 assert!(r.accepts(99));
596 assert!(!r.accepts(98));
597 }
598
599 #[test]
600 fn rollout_defaults_to_none_on_every_construction_path() {
601 assert_eq!(Workflow::new().rollout, None);
602 assert_eq!(Workflow::default().rollout, None);
603 assert_eq!(
604 Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
605 None
606 );
607 assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
608 }
609
610 fn loop_wf(loop_json: &str) -> Result<Workflow> {
616 let workflow = Workflow::from_json(&format!(
617 r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
618 ))?;
619 workflow.validate()?;
620 Ok(workflow)
621 }
622
623 #[test]
624 fn loop_config_defaults_init_zero_increment_one() {
625 let cfg = loop_wf(r#"{"max": 5}"#)
626 .expect("valid loop")
627 .loop_config
628 .expect("loop config present");
629 assert_eq!(cfg.init, 0);
630 assert_eq!(cfg.increment, 1);
631 assert_eq!(cfg.max, 5);
632 assert_eq!(cfg.counter, None);
633 }
634
635 #[test]
636 fn loop_config_is_absent_on_every_construction_path() {
637 assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
638 assert!(Workflow::new().loop_config.is_none());
639 assert!(Workflow::default().loop_config.is_none());
640 assert!(
641 Workflow::rule("r", "r", Value::Bool(true), Vec::new())
642 .loop_config
643 .is_none()
644 );
645 }
646
647 #[test]
648 fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
649 assert!(loop_wf(r#"{"max": 0}"#).is_err());
652 assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
653 assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
654 }
655
656 #[test]
657 fn loop_config_rejects_a_non_advancing_increment() {
658 assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
659 assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
660 }
661
662 #[test]
663 fn loop_config_rejects_an_empty_counter_path() {
664 assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
665 assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
666 assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
667 }
668
669 #[test]
670 fn loop_config_requires_max() {
671 assert!(
674 Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
675 );
676 }
677
678 #[test]
679 fn loop_config_deserializes_every_combination_of_optional_fields() {
680 for (json, counter, init, increment) in [
684 (r#"{"max": 9}"#, None, 0, 1),
685 (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
686 (r#"{"max": 9, "init": 4}"#, None, 4, 1),
687 (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
688 (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
689 (
690 r#"{"max": 9, "counter": "i", "increment": 3}"#,
691 Some("i"),
692 0,
693 3,
694 ),
695 (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
696 (
697 r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
698 Some("i"),
699 4,
700 3,
701 ),
702 ] {
703 let cfg = loop_wf(json)
704 .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
705 .loop_config
706 .expect("loop config present");
707 assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
708 assert_eq!(cfg.init, init, "init for {json}");
709 assert_eq!(cfg.increment, increment, "increment for {json}");
710 assert_eq!(cfg.max, 9, "max for {json}");
711 }
712 }
713
714 #[test]
715 fn loop_config_validation_matrix_over_init_increment_and_max() {
716 for (init, increment, max, valid) in [
720 (0_i64, 1_i64, 1_i64, true),
722 (0, 1, 100, true),
723 (0, 7, 3, true), (5, 1, 6, true),
725 (-5, 1, 0, true),
727 (-5, 2, -4, true),
728 (-1, 1, 1, true),
729 (0, 1, 0, false),
731 (5, 1, 5, false),
732 (5, 1, 4, false),
733 (0, 1, -1, false),
734 (-5, 1, -5, false),
735 (0, 0, 10, false),
737 (0, -1, 10, false),
738 (0, -100, 10, false),
739 ] {
740 let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
741 assert_eq!(
742 loop_wf(&json).is_ok(),
743 valid,
744 "init={init} increment={increment} max={max} should be {}",
745 if valid { "accepted" } else { "rejected" }
746 );
747 }
748 }
749
750 #[test]
751 fn loop_config_counter_path_matrix() {
752 for (counter, valid) in [
755 ("i", true),
756 ("index", true),
757 ("cursor.index", true),
758 ("a.b.c.d", true),
759 ("#7", true), ("", false),
761 (".", false),
762 ("a.", false),
763 (".a", false),
764 ("a..b", false),
765 ] {
766 let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
767 assert_eq!(
768 loop_wf(&json).is_ok(),
769 valid,
770 "counter {counter:?} should be {}",
771 if valid { "accepted" } else { "rejected" }
772 );
773 }
774 }
775
776 #[test]
777 fn precompute_counter_path_matrix() {
778 for (counter, expected) in [
779 ("i", vec!["temp_data", "i"]),
780 ("cursor.index", vec!["temp_data", "cursor", "index"]),
781 ("a.b.c", vec!["temp_data", "a", "b", "c"]),
782 ("#7", vec!["temp_data", "#7"]),
785 ] {
786 let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
787 .expect("valid loop")
788 .loop_config
789 .expect("loop config present");
790 cfg.precompute_counter_path();
791 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
792 assert_eq!(parts, expected, "for counter {counter:?}");
793 }
794 }
795
796 #[test]
797 fn precompute_counter_path_is_idempotent() {
798 let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
801 .expect("valid loop")
802 .loop_config
803 .expect("loop config present");
804 cfg.precompute_counter_path();
805 let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
806 cfg.precompute_counter_path();
807 assert_eq!(cfg.counter_parts.to_vec(), first);
808 }
809
810 #[test]
811 fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
812 for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
813 assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
814 }
815 }
816
817 #[test]
818 fn an_explicit_null_loop_means_no_loop() {
819 let workflow = loop_wf("null").expect("explicit null should be accepted");
823 assert!(workflow.loop_config.is_none());
824 }
825
826 #[test]
827 fn a_workflow_with_a_loop_still_validates_its_other_rules() {
828 let duplicate_tasks = Workflow::from_json(
831 r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
832 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
833 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
834 )
835 .expect("should parse");
836 assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
837
838 let no_tasks =
839 Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
840 .expect("should parse");
841 assert!(no_tasks.validate().is_err(), "empty task list");
842 }
843
844 #[test]
845 fn loop_config_coexists_with_every_other_workflow_field() {
846 let workflow = Workflow::from_json(&format!(
849 r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
850 "condition": {{"==": [1, 1]}},
851 "loop": {{"counter": "i", "max": 5}},
852 "continue_on_error": true, "channel": "c", "version": 3,
853 "status": "paused",
854 "rollout": {{"bucket_start": 0, "bucket_end": 50}},
855 "tags": ["x"], "tasks": [{MAP}] }}"#
856 ))
857 .expect("should parse");
858 workflow.validate().expect("should validate");
859
860 assert_eq!(workflow.priority, 7);
861 assert_eq!(workflow.channel, "c");
862 assert_eq!(workflow.version, 3);
863 assert_eq!(workflow.status, WorkflowStatus::Paused);
864 assert!(workflow.continue_on_error);
865 assert_eq!(
866 workflow.rollout,
867 Some(Rollout {
868 bucket_start: 0,
869 bucket_end: 50
870 })
871 );
872 assert_eq!(workflow.tags, ["x"]);
873 assert_eq!(
874 workflow
875 .loop_config
876 .expect("loop present")
877 .counter
878 .as_deref(),
879 Some("i")
880 );
881 }
882
883 #[test]
884 fn loop_config_accepts_a_valid_counter() {
885 let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
886 .expect("valid loop")
887 .loop_config
888 .expect("loop config present");
889 assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
890 }
891
892 #[test]
893 fn precompute_counter_path_prefixes_temp_data() {
894 let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
895 .expect("valid loop")
896 .loop_config
897 .expect("loop config present");
898 assert!(
899 cfg.counter_parts.is_empty(),
900 "uncompiled workflows start with no pre-split path"
901 );
902
903 cfg.precompute_counter_path();
904
905 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
906 assert_eq!(parts, ["temp_data", "cursor", "index"]);
907 }
908
909 #[test]
910 fn precompute_counter_path_is_empty_without_a_counter_name() {
911 let mut cfg = loop_wf(r#"{"max": 5}"#)
912 .expect("valid loop")
913 .loop_config
914 .expect("loop config present");
915 cfg.precompute_counter_path();
916 assert!(cfg.counter_parts.is_empty());
917 }
918
919 #[test]
920 fn rollout_deserializes_from_json() {
921 let workflow = Workflow::from_json(
922 r#"{ "id": "w", "name": "w", "condition": true,
923 "rollout": { "bucket_start": 0, "bucket_end": 50 },
924 "tasks": [] }"#,
925 )
926 .unwrap();
927 assert_eq!(
928 workflow.rollout,
929 Some(Rollout {
930 bucket_start: 0,
931 bucket_end: 50
932 })
933 );
934 }
935}