Skip to main content

oximedia_workflow/
workflow_import_export.rs

1//! Workflow import/export as portable YAML/JSON bundles.
2//!
3//! Provides serialization and deserialization of complete workflow definitions
4//! including tasks, edges, configuration, and metadata into self-contained
5//! bundles that can be shared, version-controlled, and imported across
6//! different OxiMedia installations.
7
8use crate::dag::{DagError, WorkflowDag, WorkflowEdge, WorkflowNode};
9use crate::error::{Result, WorkflowError};
10use crate::task::{Task, TaskType};
11use crate::workflow::{Workflow, WorkflowConfig};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15/// Format for import/export.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum BundleFormat {
18    /// JSON format.
19    Json,
20    /// YAML format.
21    Yaml,
22    /// Pretty-printed JSON.
23    JsonPretty,
24}
25
26/// Version of the bundle format.
27const BUNDLE_FORMAT_VERSION: &str = "1.0";
28
29/// A portable workflow bundle that can be serialized/deserialized.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkflowBundle {
32    /// Bundle format version.
33    pub version: String,
34    /// When the bundle was created (ISO 8601 string).
35    pub created_at: String,
36    /// Human-readable description of the bundle.
37    #[serde(default)]
38    pub description: String,
39    /// The workflow definition.
40    pub workflow: WorkflowDefinition,
41    /// Optional metadata about the bundle.
42    #[serde(default)]
43    pub metadata: HashMap<String, String>,
44    /// Tags for categorization.
45    #[serde(default)]
46    pub tags: Vec<String>,
47}
48
49/// Simplified workflow definition for portable bundles.
50///
51/// Uses string-based task references instead of UUIDs so the bundle
52/// is human-readable and can be hand-edited.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct WorkflowDefinition {
55    /// Workflow name.
56    pub name: String,
57    /// Workflow description.
58    #[serde(default)]
59    pub description: String,
60    /// Task definitions keyed by task name.
61    pub tasks: Vec<TaskDefinition>,
62    /// Dependencies as `(from_task_name, to_task_name, optional_condition)`.
63    #[serde(default)]
64    pub edges: Vec<EdgeDefinition>,
65    /// Workflow configuration.
66    #[serde(default)]
67    pub config: WorkflowConfigDef,
68    /// Workflow-level metadata.
69    #[serde(default)]
70    pub metadata: HashMap<String, String>,
71}
72
73/// A task definition within a bundle (uses names instead of UUIDs).
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct TaskDefinition {
76    /// Unique name within the workflow.
77    pub name: String,
78    /// Task type and configuration.
79    pub task_type: TaskType,
80    /// Task priority as string.
81    #[serde(default = "default_priority")]
82    pub priority: String,
83    /// Timeout in seconds.
84    #[serde(default = "default_timeout_secs")]
85    pub timeout_secs: u64,
86    /// Task conditions.
87    #[serde(default)]
88    pub conditions: Vec<String>,
89    /// Task metadata.
90    #[serde(default)]
91    pub metadata: HashMap<String, String>,
92}
93
94fn default_priority() -> String {
95    "normal".to_string()
96}
97
98fn default_timeout_secs() -> u64 {
99    3600
100}
101
102/// An edge definition within a bundle (uses task names).
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct EdgeDefinition {
105    /// Source task name.
106    pub from: String,
107    /// Destination task name.
108    pub to: String,
109    /// Optional condition expression.
110    #[serde(default)]
111    pub condition: Option<String>,
112}
113
114/// Workflow configuration definition for bundles.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct WorkflowConfigDef {
117    /// Maximum concurrent tasks.
118    #[serde(default = "default_max_concurrent")]
119    pub max_concurrent_tasks: usize,
120    /// Whether to stop on first error.
121    #[serde(default)]
122    pub fail_fast: bool,
123    /// Whether to continue on task failure.
124    #[serde(default)]
125    pub continue_on_error: bool,
126    /// Variables.
127    #[serde(default)]
128    pub variables: HashMap<String, serde_json::Value>,
129}
130
131fn default_max_concurrent() -> usize {
132    4
133}
134
135// ---------------------------------------------------------------------------
136// Export
137// ---------------------------------------------------------------------------
138
139/// Export a `Workflow` to a bundle.
140#[must_use]
141pub fn export_workflow(workflow: &Workflow) -> WorkflowBundle {
142    // Build name -> TaskId mapping for edge references
143    let mut task_defs = Vec::new();
144    let mut id_to_name: HashMap<crate::task::TaskId, String> = HashMap::new();
145
146    for task in workflow.tasks.values() {
147        id_to_name.insert(task.id, task.name.clone());
148
149        task_defs.push(TaskDefinition {
150            name: task.name.clone(),
151            task_type: task.task_type.clone(),
152            priority: format!("{:?}", task.priority).to_lowercase(),
153            timeout_secs: task.timeout.as_secs(),
154            conditions: task.conditions.clone(),
155            metadata: task.metadata.clone(),
156        });
157    }
158
159    // Sort task definitions by name for deterministic output
160    task_defs.sort_by(|a, b| a.name.cmp(&b.name));
161
162    let edges: Vec<EdgeDefinition> = workflow
163        .edges
164        .iter()
165        .filter_map(|edge| {
166            let from_name = id_to_name.get(&edge.from)?.clone();
167            let to_name = id_to_name.get(&edge.to)?.clone();
168            Some(EdgeDefinition {
169                from: from_name,
170                to: to_name,
171                condition: edge.condition.clone(),
172            })
173        })
174        .collect();
175
176    let config_def = WorkflowConfigDef {
177        max_concurrent_tasks: workflow.config.max_concurrent_tasks,
178        fail_fast: workflow.config.fail_fast,
179        continue_on_error: workflow.config.continue_on_error,
180        variables: workflow.config.variables.clone(),
181    };
182
183    let now = chrono::Utc::now().to_rfc3339();
184
185    WorkflowBundle {
186        version: BUNDLE_FORMAT_VERSION.to_string(),
187        created_at: now,
188        description: workflow.description.clone(),
189        workflow: WorkflowDefinition {
190            name: workflow.name.clone(),
191            description: workflow.description.clone(),
192            tasks: task_defs,
193            edges,
194            config: config_def,
195            metadata: workflow.metadata.clone(),
196        },
197        metadata: HashMap::new(),
198        tags: Vec::new(),
199    }
200}
201
202/// Serialize a bundle to the specified format.
203///
204/// # Errors
205///
206/// Returns an error if serialization fails.
207pub fn serialize_bundle(bundle: &WorkflowBundle, format: BundleFormat) -> Result<String> {
208    match format {
209        BundleFormat::Json => serde_json::to_string(bundle).map_err(WorkflowError::Serialization),
210        BundleFormat::JsonPretty => {
211            serde_json::to_string_pretty(bundle).map_err(WorkflowError::Serialization)
212        }
213        BundleFormat::Yaml => serde_yaml::to_string(bundle).map_err(WorkflowError::YamlParsing),
214    }
215}
216
217// ---------------------------------------------------------------------------
218// Import
219// ---------------------------------------------------------------------------
220
221/// Deserialize a bundle from a string, auto-detecting format.
222///
223/// # Errors
224///
225/// Returns an error if the string is not valid JSON or YAML.
226pub fn deserialize_bundle(data: &str) -> Result<WorkflowBundle> {
227    let trimmed = data.trim();
228
229    // Try JSON first (starts with '{')
230    if trimmed.starts_with('{') {
231        return serde_json::from_str(trimmed).map_err(WorkflowError::Serialization);
232    }
233
234    // Try YAML
235    serde_yaml::from_str(trimmed).map_err(WorkflowError::YamlParsing)
236}
237
238/// Import a bundle into a `Workflow`.
239///
240/// Creates fresh `TaskId` and `WorkflowId` values during import.
241///
242/// # Errors
243///
244/// Returns an error if the bundle references unknown tasks in edges
245/// or has other structural issues.
246pub fn import_workflow(bundle: &WorkflowBundle) -> Result<Workflow> {
247    let def = &bundle.workflow;
248
249    let mut workflow = Workflow::new(&def.name).with_description(&def.description);
250
251    // Apply config
252    workflow.config = WorkflowConfig {
253        max_concurrent_tasks: def.config.max_concurrent_tasks,
254        fail_fast: def.config.fail_fast,
255        continue_on_error: def.config.continue_on_error,
256        variables: def.config.variables.clone(),
257        global_timeout: None,
258    };
259
260    // Apply metadata
261    for (k, v) in &def.metadata {
262        workflow.metadata.insert(k.clone(), v.clone());
263    }
264
265    // Create tasks and build name -> TaskId mapping
266    let mut name_to_id: HashMap<String, crate::task::TaskId> = HashMap::new();
267
268    for task_def in &def.tasks {
269        let mut task = Task::new(&task_def.name, task_def.task_type.clone());
270        task.timeout = std::time::Duration::from_secs(task_def.timeout_secs);
271        task.conditions = task_def.conditions.clone();
272        task.metadata = task_def.metadata.clone();
273
274        // Parse priority
275        task.priority = match task_def.priority.to_lowercase().as_str() {
276            "low" => crate::task::TaskPriority::Low,
277            "high" => crate::task::TaskPriority::High,
278            "critical" => crate::task::TaskPriority::Critical,
279            _ => crate::task::TaskPriority::Normal,
280        };
281
282        let task_id = task.id;
283        name_to_id.insert(task_def.name.clone(), task_id);
284        workflow.add_task(task);
285    }
286
287    // Create edges
288    for edge_def in &def.edges {
289        let from_id = name_to_id
290            .get(&edge_def.from)
291            .ok_or_else(|| WorkflowError::TaskNotFound(edge_def.from.clone()))?;
292        let to_id = name_to_id
293            .get(&edge_def.to)
294            .ok_or_else(|| WorkflowError::TaskNotFound(edge_def.to.clone()))?;
295
296        if let Some(ref condition) = edge_def.condition {
297            workflow.add_conditional_edge(*from_id, *to_id, condition.clone())?;
298        } else {
299            workflow.add_edge(*from_id, *to_id)?;
300        }
301    }
302
303    Ok(workflow)
304}
305
306// ---------------------------------------------------------------------------
307// DAG bundle support
308// ---------------------------------------------------------------------------
309
310/// A portable bundle for `WorkflowDag` instances.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DagBundle {
313    /// Bundle format version.
314    pub version: String,
315    /// Description.
316    #[serde(default)]
317    pub description: String,
318    /// Node definitions keyed by a string identifier.
319    pub nodes: Vec<DagNodeDef>,
320    /// Edge definitions.
321    pub edges: Vec<DagEdgeDef>,
322    /// Metadata.
323    #[serde(default)]
324    pub metadata: HashMap<String, String>,
325}
326
327/// Node definition for DAG bundles.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct DagNodeDef {
330    /// Unique name within the bundle.
331    pub name: String,
332    /// Task type.
333    pub task_type: String,
334    /// Parameters.
335    #[serde(default)]
336    pub parameters: HashMap<String, serde_json::Value>,
337}
338
339/// Edge definition for DAG bundles.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct DagEdgeDef {
342    /// Source node name.
343    pub from: String,
344    /// Destination node name.
345    pub to: String,
346    /// Data type.
347    pub data_type: String,
348    /// Condition.
349    #[serde(default)]
350    pub condition: Option<String>,
351}
352
353/// Export a `WorkflowDag` to a `DagBundle`.
354#[must_use]
355pub fn export_dag(dag: &WorkflowDag, description: &str) -> DagBundle {
356    use crate::dag::NodeId;
357
358    // Assign names to nodes (use task_type + index if names collide)
359    let mut node_names: HashMap<NodeId, String> = HashMap::new();
360    let mut used_names: HashMap<String, usize> = HashMap::new();
361
362    for (node_id, node) in &dag.nodes {
363        let base_name = node.task_type.clone();
364        let count = used_names.entry(base_name.clone()).or_insert(0);
365        let name = if *count == 0 {
366            base_name.clone()
367        } else {
368            format!("{base_name}_{count}")
369        };
370        *count += 1;
371        node_names.insert(*node_id, name);
372    }
373
374    let nodes: Vec<DagNodeDef> = dag
375        .nodes
376        .iter()
377        .map(|(id, node)| DagNodeDef {
378            name: node_names.get(id).cloned().unwrap_or_default(),
379            task_type: node.task_type.clone(),
380            parameters: node.parameters.clone(),
381        })
382        .collect();
383
384    let edges: Vec<DagEdgeDef> = dag
385        .edges
386        .iter()
387        .filter_map(|edge| {
388            let from = node_names.get(&edge.from_node)?.clone();
389            let to = node_names.get(&edge.to_node)?.clone();
390            Some(DagEdgeDef {
391                from,
392                to,
393                data_type: edge.data_type.clone(),
394                condition: edge.condition.clone(),
395            })
396        })
397        .collect();
398
399    DagBundle {
400        version: BUNDLE_FORMAT_VERSION.to_string(),
401        description: description.to_string(),
402        nodes,
403        edges,
404        metadata: HashMap::new(),
405    }
406}
407
408/// Import a `DagBundle` into a `WorkflowDag`.
409///
410/// # Errors
411///
412/// Returns `DagError` if nodes are referenced in edges but not defined,
413/// or if the resulting DAG would contain a cycle.
414pub fn import_dag(bundle: &DagBundle) -> std::result::Result<WorkflowDag, DagError> {
415    let mut dag = WorkflowDag::new();
416    let mut name_to_id: HashMap<String, crate::dag::NodeId> = HashMap::new();
417
418    for node_def in &bundle.nodes {
419        let mut node = WorkflowNode::new(&node_def.task_type);
420        node.parameters = node_def.parameters.clone();
421        let id = dag.add_node(node)?;
422        name_to_id.insert(node_def.name.clone(), id);
423    }
424
425    for edge_def in &bundle.edges {
426        let from_id = name_to_id
427            .get(&edge_def.from)
428            .ok_or_else(|| DagError::NodeNotFound(crate::dag::NodeId::new()))?;
429        let to_id = name_to_id
430            .get(&edge_def.to)
431            .ok_or_else(|| DagError::NodeNotFound(crate::dag::NodeId::new()))?;
432
433        let edge = if let Some(ref condition) = edge_def.condition {
434            WorkflowEdge::with_condition(*from_id, *to_id, &edge_def.data_type, condition)
435        } else {
436            WorkflowEdge::new(*from_id, *to_id, &edge_def.data_type)
437        };
438        dag.add_edge(edge)?;
439    }
440
441    Ok(dag)
442}
443
444/// Serialize a DAG bundle.
445///
446/// # Errors
447///
448/// Returns an error if serialization fails.
449pub fn serialize_dag_bundle(bundle: &DagBundle, format: BundleFormat) -> Result<String> {
450    match format {
451        BundleFormat::Json => serde_json::to_string(bundle).map_err(WorkflowError::Serialization),
452        BundleFormat::JsonPretty => {
453            serde_json::to_string_pretty(bundle).map_err(WorkflowError::Serialization)
454        }
455        BundleFormat::Yaml => serde_yaml::to_string(bundle).map_err(WorkflowError::YamlParsing),
456    }
457}
458
459/// Deserialize a DAG bundle from a string.
460///
461/// # Errors
462///
463/// Returns an error if parsing fails.
464pub fn deserialize_dag_bundle(data: &str) -> Result<DagBundle> {
465    let trimmed = data.trim();
466    if trimmed.starts_with('{') {
467        serde_json::from_str(trimmed).map_err(WorkflowError::Serialization)
468    } else {
469        serde_yaml::from_str(trimmed).map_err(WorkflowError::YamlParsing)
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::task::{TaskPriority, TaskType};
477    use std::time::Duration;
478
479    fn make_test_workflow() -> Workflow {
480        let mut workflow = Workflow::new("test-pipeline")
481            .with_description("A test pipeline")
482            .with_metadata("env", "test");
483
484        let task1 = Task::new(
485            "ingest",
486            TaskType::Wait {
487                duration: Duration::from_secs(5),
488            },
489        )
490        .with_priority(TaskPriority::High);
491        let task2 = Task::new(
492            "transcode",
493            TaskType::Wait {
494                duration: Duration::from_secs(60),
495            },
496        );
497        let task3 = Task::new(
498            "deliver",
499            TaskType::Wait {
500                duration: Duration::from_secs(10),
501            },
502        )
503        .with_priority(TaskPriority::Low);
504
505        let id1 = workflow.add_task(task1);
506        let id2 = workflow.add_task(task2);
507        let id3 = workflow.add_task(task3);
508
509        workflow.add_edge(id1, id2).expect("add edge");
510        workflow.add_edge(id2, id3).expect("add edge");
511
512        workflow
513    }
514
515    // --- Export ---
516
517    #[test]
518    fn test_export_workflow() {
519        let workflow = make_test_workflow();
520        let bundle = export_workflow(&workflow);
521
522        assert_eq!(bundle.version, "1.0");
523        assert_eq!(bundle.workflow.name, "test-pipeline");
524        assert_eq!(bundle.workflow.tasks.len(), 3);
525        assert_eq!(bundle.workflow.edges.len(), 2);
526    }
527
528    #[test]
529    fn test_export_preserves_metadata() {
530        let workflow = make_test_workflow();
531        let bundle = export_workflow(&workflow);
532
533        assert_eq!(
534            bundle.workflow.metadata.get("env"),
535            Some(&"test".to_string())
536        );
537    }
538
539    #[test]
540    fn test_export_edges_use_names() {
541        let workflow = make_test_workflow();
542        let bundle = export_workflow(&workflow);
543
544        // Edges should reference task names, not UUIDs
545        let edge_names: Vec<(&str, &str)> = bundle
546            .workflow
547            .edges
548            .iter()
549            .map(|e| (e.from.as_str(), e.to.as_str()))
550            .collect();
551        assert!(edge_names.contains(&("ingest", "transcode")));
552        assert!(edge_names.contains(&("transcode", "deliver")));
553    }
554
555    // --- Serialize/Deserialize ---
556
557    #[test]
558    fn test_serialize_json() {
559        let workflow = make_test_workflow();
560        let bundle = export_workflow(&workflow);
561        let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
562
563        assert!(json.contains("test-pipeline"));
564        assert!(!json.contains('\n')); // compact JSON
565    }
566
567    #[test]
568    fn test_serialize_json_pretty() {
569        let workflow = make_test_workflow();
570        let bundle = export_workflow(&workflow);
571        let json = serialize_bundle(&bundle, BundleFormat::JsonPretty).expect("serialize");
572
573        assert!(json.contains("test-pipeline"));
574        assert!(json.contains('\n')); // pretty-printed
575    }
576
577    #[test]
578    fn test_serialize_yaml() {
579        let workflow = make_test_workflow();
580        let bundle = export_workflow(&workflow);
581        let yaml = serialize_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
582
583        assert!(yaml.contains("test-pipeline"));
584        assert!(yaml.contains("name:"));
585    }
586
587    #[test]
588    fn test_deserialize_json() {
589        let workflow = make_test_workflow();
590        let bundle = export_workflow(&workflow);
591        let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
592
593        let restored = deserialize_bundle(&json).expect("deserialize");
594        assert_eq!(restored.workflow.name, "test-pipeline");
595        assert_eq!(restored.workflow.tasks.len(), 3);
596    }
597
598    #[test]
599    fn test_deserialize_yaml() {
600        let workflow = make_test_workflow();
601        let bundle = export_workflow(&workflow);
602        let yaml = serialize_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
603
604        let restored = deserialize_bundle(&yaml).expect("deserialize");
605        assert_eq!(restored.workflow.name, "test-pipeline");
606    }
607
608    // --- Import ---
609
610    #[test]
611    fn test_import_workflow() {
612        let workflow = make_test_workflow();
613        let bundle = export_workflow(&workflow);
614        let imported = import_workflow(&bundle).expect("import");
615
616        assert_eq!(imported.name, "test-pipeline");
617        assert_eq!(imported.tasks.len(), 3);
618        assert_eq!(imported.edges.len(), 2);
619    }
620
621    #[test]
622    fn test_import_preserves_config() {
623        let mut workflow = make_test_workflow();
624        workflow.config.fail_fast = true;
625        workflow.config.max_concurrent_tasks = 8;
626
627        let bundle = export_workflow(&workflow);
628        let imported = import_workflow(&bundle).expect("import");
629
630        assert!(imported.config.fail_fast);
631        assert_eq!(imported.config.max_concurrent_tasks, 8);
632    }
633
634    #[test]
635    fn test_import_preserves_priority() {
636        let workflow = make_test_workflow();
637        let bundle = export_workflow(&workflow);
638        let imported = import_workflow(&bundle).expect("import");
639
640        let ingest_task = imported
641            .tasks
642            .values()
643            .find(|t| t.name == "ingest")
644            .expect("find task");
645        assert_eq!(ingest_task.priority, TaskPriority::High);
646    }
647
648    #[test]
649    fn test_roundtrip_json() {
650        let workflow = make_test_workflow();
651        let bundle = export_workflow(&workflow);
652        let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
653        let restored = deserialize_bundle(&json).expect("deserialize");
654        let imported = import_workflow(&restored).expect("import");
655
656        assert_eq!(imported.name, workflow.name);
657        assert_eq!(imported.tasks.len(), workflow.tasks.len());
658        assert_eq!(imported.edges.len(), workflow.edges.len());
659    }
660
661    #[test]
662    fn test_roundtrip_yaml() {
663        let workflow = make_test_workflow();
664        let bundle = export_workflow(&workflow);
665        let yaml = serialize_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
666        let restored = deserialize_bundle(&yaml).expect("deserialize");
667        let imported = import_workflow(&restored).expect("import");
668
669        assert_eq!(imported.name, workflow.name);
670        assert_eq!(imported.tasks.len(), workflow.tasks.len());
671    }
672
673    #[test]
674    fn test_import_bad_edge_reference() {
675        let mut bundle = export_workflow(&make_test_workflow());
676        bundle.workflow.edges.push(EdgeDefinition {
677            from: "nonexistent".to_string(),
678            to: "ingest".to_string(),
679            condition: None,
680        });
681
682        let result = import_workflow(&bundle);
683        assert!(result.is_err());
684    }
685
686    // --- Conditional edges ---
687
688    #[test]
689    fn test_conditional_edge_roundtrip() {
690        let mut workflow = Workflow::new("conditional-wf");
691        let task1 = Task::new(
692            "check",
693            TaskType::Wait {
694                duration: Duration::from_secs(1),
695            },
696        );
697        let task2 = Task::new(
698            "branch_a",
699            TaskType::Wait {
700                duration: Duration::from_secs(1),
701            },
702        );
703        let id1 = workflow.add_task(task1);
704        let id2 = workflow.add_task(task2);
705        workflow
706            .add_conditional_edge(id1, id2, "result == success".to_string())
707            .expect("add edge");
708
709        let bundle = export_workflow(&workflow);
710        assert_eq!(
711            bundle.workflow.edges[0].condition,
712            Some("result == success".to_string())
713        );
714
715        let imported = import_workflow(&bundle).expect("import");
716        assert_eq!(
717            imported.edges[0].condition,
718            Some("result == success".to_string())
719        );
720    }
721
722    // --- DAG bundles ---
723
724    #[test]
725    fn test_dag_export() {
726        let mut dag = WorkflowDag::new();
727        let a = dag.add_node(WorkflowNode::new("ingest")).expect("add node");
728        let b = dag
729            .add_node(WorkflowNode::new("transcode"))
730            .expect("add node");
731        dag.add_edge(WorkflowEdge::new(a, b, "raw_media"))
732            .expect("add edge");
733
734        let bundle = export_dag(&dag, "test dag");
735        assert_eq!(bundle.nodes.len(), 2);
736        assert_eq!(bundle.edges.len(), 1);
737        assert_eq!(bundle.description, "test dag");
738    }
739
740    #[test]
741    fn test_dag_roundtrip() {
742        let mut dag = WorkflowDag::new();
743        let a = dag.add_node(WorkflowNode::new("ingest")).expect("add node");
744        let b = dag.add_node(WorkflowNode::new("encode")).expect("add node");
745        let c = dag
746            .add_node(WorkflowNode::new("deliver"))
747            .expect("add node");
748        dag.add_edge(WorkflowEdge::new(a, b, "raw")).expect("edge");
749        dag.add_edge(WorkflowEdge::new(b, c, "encoded"))
750            .expect("edge");
751
752        let bundle = export_dag(&dag, "pipeline");
753        let json = serialize_dag_bundle(&bundle, BundleFormat::JsonPretty).expect("serialize");
754        let restored_bundle = deserialize_dag_bundle(&json).expect("deserialize");
755        let restored_dag = import_dag(&restored_bundle).expect("import");
756
757        assert_eq!(restored_dag.nodes.len(), 3);
758        assert_eq!(restored_dag.edges.len(), 2);
759        assert!(!restored_dag.has_cycle());
760    }
761
762    #[test]
763    fn test_dag_yaml_roundtrip() {
764        let mut dag = WorkflowDag::new();
765        let a = dag.add_node(WorkflowNode::new("src")).expect("add node");
766        let b = dag.add_node(WorkflowNode::new("dst")).expect("add node");
767        dag.add_edge(WorkflowEdge::new(a, b, "data")).expect("edge");
768
769        let bundle = export_dag(&dag, "simple");
770        let yaml = serialize_dag_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
771        let restored = deserialize_dag_bundle(&yaml).expect("deserialize");
772        let imported = import_dag(&restored).expect("import");
773
774        assert_eq!(imported.nodes.len(), 2);
775    }
776
777    #[test]
778    fn test_dag_with_parameters() {
779        let mut dag = WorkflowDag::new();
780        let node = WorkflowNode::new("encode")
781            .with_parameter("preset", serde_json::json!("slow"))
782            .with_parameter("crf", serde_json::json!(23));
783        let _ = dag.add_node(node).expect("add node");
784
785        let bundle = export_dag(&dag, "params test");
786        let json = serialize_dag_bundle(&bundle, BundleFormat::Json).expect("serialize");
787        let restored = deserialize_dag_bundle(&json).expect("deserialize");
788        let imported = import_dag(&restored).expect("import");
789
790        let imported_node = imported.nodes.values().next().expect("get node");
791        assert_eq!(
792            imported_node.parameters["preset"],
793            serde_json::json!("slow")
794        );
795        assert_eq!(imported_node.parameters["crf"], serde_json::json!(23));
796    }
797
798    #[test]
799    fn test_bundle_format_version() {
800        let workflow = make_test_workflow();
801        let bundle = export_workflow(&workflow);
802        assert_eq!(bundle.version, "1.0");
803    }
804
805    #[test]
806    fn test_bundle_tags() {
807        let workflow = make_test_workflow();
808        let mut bundle = export_workflow(&workflow);
809        bundle.tags = vec!["media".to_string(), "transcode".to_string()];
810
811        let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
812        let restored = deserialize_bundle(&json).expect("deserialize");
813        assert_eq!(restored.tags, vec!["media", "transcode"]);
814    }
815}