1use serde::{Deserialize, Serialize};
5use std::cell::RefCell;
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::sync::{Arc, Mutex};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct NodeId(Uuid);
17
18impl NodeId {
19 #[must_use]
21 pub fn new() -> Self {
22 Self(Uuid::new_v4())
23 }
24
25 #[must_use]
27 pub const fn as_uuid(&self) -> &Uuid {
28 &self.0
29 }
30}
31
32impl Default for NodeId {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl std::fmt::Display for NodeId {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(f, "{}", self.0)
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub enum NodeStatus {
47 Pending,
49 Waiting,
51 Running,
53 Completed,
55 Failed(String),
57 Skipped,
59}
60
61impl NodeStatus {
62 #[must_use]
64 pub fn is_terminal(&self) -> bool {
65 matches!(self, Self::Completed | Self::Failed(_) | Self::Skipped)
66 }
67
68 #[must_use]
70 pub fn is_success(&self) -> bool {
71 matches!(self, Self::Completed)
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct WorkflowNode {
81 pub node_id: NodeId,
83 pub task_type: String,
85 pub inputs: HashMap<String, serde_json::Value>,
87 pub outputs: HashMap<String, serde_json::Value>,
89 pub parameters: HashMap<String, serde_json::Value>,
91 pub status: NodeStatus,
93}
94
95impl WorkflowNode {
96 #[must_use]
98 pub fn new(task_type: impl Into<String>) -> Self {
99 Self {
100 node_id: NodeId::new(),
101 task_type: task_type.into(),
102 inputs: HashMap::new(),
103 outputs: HashMap::new(),
104 parameters: HashMap::new(),
105 status: NodeStatus::Pending,
106 }
107 }
108
109 #[must_use]
111 pub fn with_input(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
112 self.inputs.insert(key.into(), value);
113 self
114 }
115
116 #[must_use]
118 pub fn with_parameter(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
119 self.parameters.insert(key.into(), value);
120 self
121 }
122
123 pub fn set_output(&mut self, key: impl Into<String>, value: serde_json::Value) {
125 self.outputs.insert(key.into(), value);
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct WorkflowEdge {
136 pub from_node: NodeId,
138 pub to_node: NodeId,
140 pub data_type: String,
142 pub condition: Option<String>,
144}
145
146impl WorkflowEdge {
147 #[must_use]
149 pub fn new(from_node: NodeId, to_node: NodeId, data_type: impl Into<String>) -> Self {
150 Self {
151 from_node,
152 to_node,
153 data_type: data_type.into(),
154 condition: None,
155 }
156 }
157
158 #[must_use]
160 pub fn with_condition(
161 from_node: NodeId,
162 to_node: NodeId,
163 data_type: impl Into<String>,
164 condition: impl Into<String>,
165 ) -> Self {
166 Self {
167 from_node,
168 to_node,
169 data_type: data_type.into(),
170 condition: Some(condition.into()),
171 }
172 }
173}
174
175#[derive(Debug, thiserror::Error)]
181pub enum DagError {
182 #[error("Node not found: {0}")]
184 NodeNotFound(NodeId),
185
186 #[error("Cycle detected in DAG")]
188 CycleDetected,
189
190 #[error("Node already exists: {0}")]
192 DuplicateNode(NodeId),
193}
194
195#[derive(Debug, Serialize, Deserialize)]
203pub struct WorkflowDag {
204 pub nodes: HashMap<NodeId, WorkflowNode>,
206 pub edges: Vec<WorkflowEdge>,
208 #[serde(skip)]
211 topo_cache: RefCell<Option<Vec<NodeId>>>,
212}
213
214impl Clone for WorkflowDag {
215 fn clone(&self) -> Self {
216 Self {
217 nodes: self.nodes.clone(),
218 edges: self.edges.clone(),
219 topo_cache: RefCell::new(None),
221 }
222 }
223}
224
225impl Default for WorkflowDag {
226 fn default() -> Self {
227 Self {
228 nodes: HashMap::new(),
229 edges: Vec::new(),
230 topo_cache: RefCell::new(None),
231 }
232 }
233}
234
235impl WorkflowDag {
236 #[must_use]
238 pub fn new() -> Self {
239 Self::default()
240 }
241
242 fn invalidate_topo_cache(&self) {
244 *self.topo_cache.borrow_mut() = None;
245 }
246
247 pub fn add_node(&mut self, node: WorkflowNode) -> Result<NodeId, DagError> {
249 let id = node.node_id;
250 if self.nodes.contains_key(&id) {
251 return Err(DagError::DuplicateNode(id));
252 }
253 self.nodes.insert(id, node);
254 self.invalidate_topo_cache();
255 Ok(id)
256 }
257
258 pub fn add_edge(&mut self, edge: WorkflowEdge) -> Result<(), DagError> {
260 if !self.nodes.contains_key(&edge.from_node) {
261 return Err(DagError::NodeNotFound(edge.from_node));
262 }
263 if !self.nodes.contains_key(&edge.to_node) {
264 return Err(DagError::NodeNotFound(edge.to_node));
265 }
266 self.edges.push(edge);
267
268 if self.has_cycle() {
270 self.edges.pop();
271 return Err(DagError::CycleDetected);
272 }
273 self.invalidate_topo_cache();
274 Ok(())
275 }
276
277 #[must_use]
279 pub fn has_cycle(&self) -> bool {
280 let mut visited = HashSet::new();
281 let mut stack = HashSet::new();
282 for &id in self.nodes.keys() {
283 if self.dfs_cycle(id, &mut visited, &mut stack) {
284 return true;
285 }
286 }
287 false
288 }
289
290 fn dfs_cycle(
291 &self,
292 id: NodeId,
293 visited: &mut HashSet<NodeId>,
294 stack: &mut HashSet<NodeId>,
295 ) -> bool {
296 if stack.contains(&id) {
297 return true;
298 }
299 if visited.contains(&id) {
300 return false;
301 }
302 visited.insert(id);
303 stack.insert(id);
304 for edge in &self.edges {
305 if edge.from_node == id && self.dfs_cycle(edge.to_node, visited, stack) {
306 return true;
307 }
308 }
309 stack.remove(&id);
310 false
311 }
312
313 pub fn topological_sort(&self) -> Result<Vec<NodeId>, DagError> {
319 {
321 let cached = self.topo_cache.borrow();
322 if let Some(ref order) = *cached {
323 return Ok(order.clone());
324 }
325 }
326
327 if self.has_cycle() {
328 return Err(DagError::CycleDetected);
329 }
330
331 let mut in_degree: HashMap<NodeId, usize> = self.nodes.keys().map(|&k| (k, 0)).collect();
333 for edge in &self.edges {
334 *in_degree.entry(edge.to_node).or_insert(0) += 1;
335 }
336
337 let mut queue: VecDeque<NodeId> = in_degree
338 .iter()
339 .filter(|(_, °)| deg == 0)
340 .map(|(&id, _)| id)
341 .collect();
342
343 let mut result = Vec::with_capacity(self.nodes.len());
344 while let Some(id) = queue.pop_front() {
345 result.push(id);
346 for edge in &self.edges {
347 if edge.from_node == id {
348 let deg = in_degree.entry(edge.to_node).or_insert(0);
349 *deg = deg.saturating_sub(1);
350 if *deg == 0 {
351 queue.push_back(edge.to_node);
352 }
353 }
354 }
355 }
356
357 *self.topo_cache.borrow_mut() = Some(result.clone());
359
360 Ok(result)
361 }
362
363 #[must_use]
365 pub fn predecessors(&self, node_id: NodeId) -> Vec<NodeId> {
366 self.edges
367 .iter()
368 .filter(|e| e.to_node == node_id)
369 .map(|e| e.from_node)
370 .collect()
371 }
372
373 #[must_use]
375 pub fn successors(&self, node_id: NodeId) -> Vec<NodeId> {
376 self.edges
377 .iter()
378 .filter(|e| e.from_node == node_id)
379 .map(|e| e.to_node)
380 .collect()
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct DagRunStatus {
391 pub node_statuses: HashMap<NodeId, NodeStatus>,
393 pub succeeded: bool,
395 pub nodes_executed: usize,
397 pub nodes_failed: usize,
399}
400
401pub struct DagWorkflowEngine {
406 executor: Option<Arc<dyn Fn(&mut WorkflowNode) -> Result<(), String> + Send + Sync>>,
408 statuses: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
410}
411
412impl DagWorkflowEngine {
413 #[must_use]
415 pub fn new() -> Self {
416 Self {
417 executor: None,
418 statuses: Arc::new(Mutex::new(HashMap::new())),
419 }
420 }
421
422 #[must_use]
424 pub fn with_executor<F>(mut self, f: F) -> Self
425 where
426 F: Fn(&mut WorkflowNode) -> Result<(), String> + Send + Sync + 'static,
427 {
428 self.executor = Some(Arc::new(f));
429 self
430 }
431
432 pub fn execute(&self, dag: &mut WorkflowDag) -> Result<DagRunStatus, DagError> {
438 let order = dag.topological_sort()?;
439
440 let mut statuses: HashMap<NodeId, NodeStatus> = HashMap::new();
441 let mut nodes_executed = 0usize;
442 let mut nodes_failed = 0usize;
443
444 for node_id in &order {
445 let Some(node) = dag.nodes.get_mut(node_id) else {
446 continue;
447 };
448
449 node.status = NodeStatus::Running;
450 statuses.insert(*node_id, NodeStatus::Running);
451
452 let result = if let Some(ref exec) = self.executor {
453 exec(node)
454 } else {
455 Ok(())
456 };
457
458 match result {
459 Ok(()) => {
460 node.status = NodeStatus::Completed;
461 statuses.insert(*node_id, NodeStatus::Completed);
462 nodes_executed += 1;
463 }
464 Err(msg) => {
465 node.status = NodeStatus::Failed(msg.clone());
466 statuses.insert(*node_id, NodeStatus::Failed(msg));
467 nodes_failed += 1;
468 }
469 }
470 }
471
472 if let Ok(mut guard) = self.statuses.lock() {
474 guard.extend(statuses.clone());
475 }
476
477 let succeeded = nodes_failed == 0;
478
479 Ok(DagRunStatus {
480 node_statuses: statuses,
481 succeeded,
482 nodes_executed,
483 nodes_failed,
484 })
485 }
486
487 #[must_use]
489 pub fn node_status(&self, node_id: NodeId) -> Option<NodeStatus> {
490 self.statuses.lock().ok()?.get(&node_id).cloned()
491 }
492}
493
494impl Default for DagWorkflowEngine {
495 fn default() -> Self {
496 Self::new()
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct WorkflowTemplate {
508 pub name: String,
510 pub description: String,
512 pub default_parameters: HashMap<String, serde_json::Value>,
514 node_specs: Vec<NodeSpec>,
516 edge_specs: Vec<(usize, usize, String)>,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522struct NodeSpec {
523 task_type: String,
524 parameters: HashMap<String, serde_json::Value>,
525}
526
527impl WorkflowTemplate {
528 #[must_use]
530 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
531 Self {
532 name: name.into(),
533 description: description.into(),
534 default_parameters: HashMap::new(),
535 node_specs: Vec::new(),
536 edge_specs: Vec::new(),
537 }
538 }
539
540 #[must_use]
542 pub fn with_default_parameter(
543 mut self,
544 key: impl Into<String>,
545 value: serde_json::Value,
546 ) -> Self {
547 self.default_parameters.insert(key.into(), value);
548 self
549 }
550
551 pub fn add_node(
553 &mut self,
554 task_type: impl Into<String>,
555 parameters: HashMap<String, serde_json::Value>,
556 ) -> usize {
557 self.node_specs.push(NodeSpec {
558 task_type: task_type.into(),
559 parameters,
560 });
561 self.node_specs.len() - 1
562 }
563
564 pub fn add_edge(&mut self, from_idx: usize, to_idx: usize, data_type: impl Into<String>) {
566 self.edge_specs.push((from_idx, to_idx, data_type.into()));
567 }
568
569 pub fn instantiate(
573 &self,
574 overrides: &HashMap<String, serde_json::Value>,
575 ) -> Result<WorkflowDag, DagError> {
576 let mut dag = WorkflowDag::new();
577 let mut ids: Vec<NodeId> = Vec::with_capacity(self.node_specs.len());
578
579 for spec in &self.node_specs {
580 let mut params = self.default_parameters.clone();
581 params.extend(spec.parameters.clone());
582 params.extend(overrides.clone());
583
584 let node = WorkflowNode {
585 node_id: NodeId::new(),
586 task_type: spec.task_type.clone(),
587 inputs: HashMap::new(),
588 outputs: HashMap::new(),
589 parameters: params,
590 status: NodeStatus::Pending,
591 };
592 let id = dag.add_node(node)?;
593 ids.push(id);
594 }
595
596 for &(from_idx, to_idx, ref dt) in &self.edge_specs {
597 let edge = WorkflowEdge::new(ids[from_idx], ids[to_idx], dt.clone());
598 dag.add_edge(edge)?;
599 }
600
601 Ok(dag)
602 }
603
604 #[must_use]
606 pub fn node_count(&self) -> usize {
607 self.node_specs.len()
608 }
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
617pub enum BranchType {
618 IfElse {
621 condition: String,
623 then_branch: NodeId,
625 else_branch: NodeId,
627 },
628 Switch {
631 key: String,
633 cases: HashMap<String, NodeId>,
635 default: Option<NodeId>,
637 },
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct BranchNode {
643 pub node_id: NodeId,
645 pub predecessor: NodeId,
647 pub branch_type: BranchType,
649}
650
651pub struct BranchEvaluator;
653
654impl BranchEvaluator {
655 #[must_use]
669 pub fn evaluate_condition(
670 condition: &str,
671 outputs: &HashMap<String, serde_json::Value>,
672 ) -> Option<bool> {
673 let parts: Vec<&str> = condition.trim().splitn(3, ' ').collect();
674 if parts.len() < 2 {
675 return None;
676 }
677
678 let key = parts[0];
679
680 if parts.len() == 2 {
682 return match parts[1] {
683 "exists" => Some(outputs.contains_key(key)),
684 "not_exists" => Some(!outputs.contains_key(key)),
685 _ => None,
686 };
687 }
688
689 let op = parts[1];
690 let rhs = parts[2];
691 let lhs_val = outputs.get(key)?;
692
693 match op {
694 "==" => {
695 let rhs_trimmed = rhs.trim_matches('"');
696 if let Some(s) = lhs_val.as_str() {
697 Some(s == rhs_trimmed)
698 } else if let Some(n) = lhs_val.as_f64() {
699 rhs.parse::<f64>()
700 .ok()
701 .map(|r| (n - r).abs() < f64::EPSILON)
702 } else if let Some(b) = lhs_val.as_bool() {
703 rhs.parse::<bool>().ok().map(|r| b == r)
704 } else {
705 None
706 }
707 }
708 "!=" => {
709 let eq = Self::evaluate_condition(&format!("{key} == {rhs}"), outputs)?;
710 Some(!eq)
711 }
712 ">" | ">=" | "<" | "<=" => {
713 let lhs_num = lhs_val.as_f64()?;
714 let rhs_num = rhs.parse::<f64>().ok()?;
715 Some(match op {
716 ">" => lhs_num > rhs_num,
717 ">=" => lhs_num >= rhs_num,
718 "<" => lhs_num < rhs_num,
719 "<=" => lhs_num <= rhs_num,
720 _ => return None,
721 })
722 }
723 _ => None,
724 }
725 }
726
727 #[must_use]
730 pub fn resolve_branch(
731 branch: &BranchNode,
732 predecessor_outputs: &HashMap<String, serde_json::Value>,
733 ) -> Vec<NodeId> {
734 match &branch.branch_type {
735 BranchType::IfElse {
736 condition,
737 then_branch,
738 else_branch,
739 } => {
740 let result =
741 Self::evaluate_condition(condition, predecessor_outputs).unwrap_or(false);
742 if result {
743 vec![*then_branch]
744 } else {
745 vec![*else_branch]
746 }
747 }
748 BranchType::Switch {
749 key,
750 cases,
751 default,
752 } => {
753 if let Some(val) = predecessor_outputs.get(key) {
754 let val_str = if let Some(s) = val.as_str() {
755 s.to_string()
756 } else {
757 val.to_string()
758 };
759 if let Some(&target) = cases.get(&val_str) {
760 return vec![target];
761 }
762 }
763 default.map_or_else(Vec::new, |d| vec![d])
764 }
765 }
766 }
767}
768
769impl WorkflowDag {
770 pub fn execute_with_branches(
780 &mut self,
781 branches: &HashMap<NodeId, BranchNode>,
782 executor: Option<&dyn Fn(&mut WorkflowNode) -> Result<(), String>>,
783 ) -> Result<DagRunStatus, DagError> {
784 let order = self.topological_sort()?;
785
786 let mut statuses: HashMap<NodeId, NodeStatus> = HashMap::new();
787 let mut nodes_executed = 0usize;
788 let mut nodes_failed = 0usize;
789 let mut skipped_nodes: HashSet<NodeId> = HashSet::new();
790
791 for &node_id in &order {
792 if skipped_nodes.contains(&node_id) {
794 if let Some(node) = self.nodes.get_mut(&node_id) {
795 node.status = NodeStatus::Skipped;
796 }
797 statuses.insert(node_id, NodeStatus::Skipped);
798 continue;
799 }
800
801 let Some(node) = self.nodes.get_mut(&node_id) else {
802 continue;
803 };
804
805 node.status = NodeStatus::Running;
806 statuses.insert(node_id, NodeStatus::Running);
807
808 let result = if let Some(exec) = &executor {
809 exec(node)
810 } else {
811 Ok(())
812 };
813
814 match result {
815 Ok(()) => {
816 node.status = NodeStatus::Completed;
817 statuses.insert(node_id, NodeStatus::Completed);
818 nodes_executed += 1;
819
820 if let Some(branch) = branches.get(&node_id) {
822 let predecessor_outputs = self
823 .nodes
824 .get(&branch.predecessor)
825 .map(|n| n.outputs.clone())
826 .unwrap_or_default();
827 let selected =
828 BranchEvaluator::resolve_branch(branch, &predecessor_outputs);
829
830 let all_successors = self.successors(node_id);
832 for succ_id in &all_successors {
833 if !selected.contains(succ_id) {
834 Self::collect_descendants_static(
835 &self.edges,
836 *succ_id,
837 &mut skipped_nodes,
838 );
839 skipped_nodes.insert(*succ_id);
840 }
841 }
842 }
843 }
844 Err(msg) => {
845 node.status = NodeStatus::Failed(msg.clone());
846 statuses.insert(node_id, NodeStatus::Failed(msg));
847 nodes_failed += 1;
848 }
849 }
850 }
851
852 let succeeded = nodes_failed == 0;
853 Ok(DagRunStatus {
854 node_statuses: statuses,
855 succeeded,
856 nodes_executed,
857 nodes_failed,
858 })
859 }
860
861 fn collect_descendants_static(
863 edges: &[WorkflowEdge],
864 node_id: NodeId,
865 result: &mut HashSet<NodeId>,
866 ) {
867 for edge in edges {
868 if edge.from_node == node_id && !result.contains(&edge.to_node) {
869 result.insert(edge.to_node);
870 Self::collect_descendants_static(edges, edge.to_node, result);
871 }
872 }
873 }
874
875 #[must_use]
877 pub fn descendants(&self, node_id: NodeId) -> HashSet<NodeId> {
878 let mut result = HashSet::new();
879 Self::collect_descendants_static(&self.edges, node_id, &mut result);
880 result
881 }
882}
883
884#[must_use]
892pub fn ingest_transcode() -> WorkflowTemplate {
893 let mut tmpl = WorkflowTemplate::new(
894 "ingest_transcode",
895 "Ingest a source file, probe it, transcode, then package for delivery",
896 )
897 .with_default_parameter("output_format", serde_json::json!("mp4"))
898 .with_default_parameter("preset", serde_json::json!("medium"));
899
900 let i0 = tmpl.add_node("ingest", HashMap::new());
901 let i1 = tmpl.add_node("probe", HashMap::new());
902 let i2 = tmpl.add_node("transcode", HashMap::new());
903 let i3 = tmpl.add_node("package", HashMap::new());
904
905 tmpl.add_edge(i0, i1, "raw_media");
906 tmpl.add_edge(i1, i2, "media_info");
907 tmpl.add_edge(i2, i3, "encoded_video");
908
909 tmpl
910}
911
912#[must_use]
916pub fn subtitle_burn() -> WorkflowTemplate {
917 let mut tmpl = WorkflowTemplate::new(
918 "subtitle_burn",
919 "Parse subtitle file and burn it into the video stream",
920 )
921 .with_default_parameter("subtitle_format", serde_json::json!("srt"))
922 .with_default_parameter("font_size", serde_json::json!(24));
923
924 let i0 = tmpl.add_node("ingest", HashMap::new());
925 let i1 = tmpl.add_node("subtitle_parse", HashMap::new());
926 let i2 = tmpl.add_node("burn_subtitles", HashMap::new());
927 let i3 = tmpl.add_node("encode", HashMap::new());
928 let i4 = tmpl.add_node("deliver", HashMap::new());
929
930 tmpl.add_edge(i0, i1, "raw_media");
931 tmpl.add_edge(i1, i2, "subtitle_events");
932 tmpl.add_edge(i2, i3, "filtered_video");
933 tmpl.add_edge(i3, i4, "encoded_video");
934
935 tmpl
936}
937
938#[must_use]
942pub fn audio_normalize() -> WorkflowTemplate {
943 let mut tmpl = WorkflowTemplate::new(
944 "audio_normalize",
945 "Analyze audio loudness and normalize to a target LUFS level",
946 )
947 .with_default_parameter("target_lufs", serde_json::json!(-23.0))
948 .with_default_parameter("true_peak_dbtp", serde_json::json!(-1.0));
949
950 let i0 = tmpl.add_node("ingest", HashMap::new());
951 let i1 = tmpl.add_node("audio_analyze", HashMap::new());
952 let i2 = tmpl.add_node("normalize", HashMap::new());
953 let i3 = tmpl.add_node("encode", HashMap::new());
954 let i4 = tmpl.add_node("deliver", HashMap::new());
955
956 tmpl.add_edge(i0, i1, "raw_audio");
957 tmpl.add_edge(i1, i2, "loudness_stats");
958 tmpl.add_edge(i2, i3, "normalized_audio");
959 tmpl.add_edge(i3, i4, "encoded_audio");
960
961 tmpl
962}
963
964#[cfg(test)]
969mod tests {
970 use super::*;
971
972 fn make_node(task_type: &str) -> WorkflowNode {
973 WorkflowNode::new(task_type)
974 }
975
976 #[test]
979 fn test_node_creation() {
980 let node = make_node("transcode");
981 assert_eq!(node.task_type, "transcode");
982 assert_eq!(node.status, NodeStatus::Pending);
983 assert!(node.inputs.is_empty());
984 assert!(node.outputs.is_empty());
985 assert!(node.parameters.is_empty());
986 }
987
988 #[test]
989 fn test_node_with_input_and_parameter() {
990 let in_path = std::env::temp_dir()
991 .join("oximedia-workflow-dag-in.mp4")
992 .to_string_lossy()
993 .into_owned();
994 let node = make_node("encode")
995 .with_input("src", serde_json::json!(in_path))
996 .with_parameter("preset", serde_json::json!("slow"));
997 assert_eq!(node.inputs["src"], serde_json::json!(in_path));
998 assert_eq!(node.parameters["preset"], serde_json::json!("slow"));
999 }
1000
1001 #[test]
1002 fn test_node_set_output() {
1003 let mut node = make_node("transcode");
1004 let out_path = std::env::temp_dir()
1005 .join("oximedia-workflow-dag-out.mp4")
1006 .to_string_lossy()
1007 .into_owned();
1008 node.set_output("dst", serde_json::json!(out_path));
1009 assert!(node.outputs.contains_key("dst"));
1010 }
1011
1012 #[test]
1013 fn test_node_status_terminal() {
1014 assert!(!NodeStatus::Pending.is_terminal());
1015 assert!(!NodeStatus::Running.is_terminal());
1016 assert!(NodeStatus::Completed.is_terminal());
1017 assert!(NodeStatus::Failed("err".to_string()).is_terminal());
1018 assert!(NodeStatus::Skipped.is_terminal());
1019 }
1020
1021 #[test]
1024 fn test_edge_creation() {
1025 let a = NodeId::new();
1026 let b = NodeId::new();
1027 let edge = WorkflowEdge::new(a, b, "video/mp4");
1028 assert_eq!(edge.from_node, a);
1029 assert_eq!(edge.to_node, b);
1030 assert_eq!(edge.data_type, "video/mp4");
1031 assert!(edge.condition.is_none());
1032 }
1033
1034 #[test]
1035 fn test_edge_with_condition() {
1036 let a = NodeId::new();
1037 let b = NodeId::new();
1038 let edge = WorkflowEdge::with_condition(a, b, "audio/pcm", "bitrate > 128");
1039 assert_eq!(edge.condition, Some("bitrate > 128".to_string()));
1040 }
1041
1042 #[test]
1045 fn test_dag_add_node_and_edge() {
1046 let mut dag = WorkflowDag::new();
1047 let a = dag
1048 .add_node(make_node("ingest"))
1049 .expect("should succeed in test");
1050 let b = dag
1051 .add_node(make_node("transcode"))
1052 .expect("should succeed in test");
1053 dag.add_edge(WorkflowEdge::new(a, b, "raw_media"))
1054 .expect("should succeed in test");
1055
1056 assert_eq!(dag.nodes.len(), 2);
1057 assert_eq!(dag.edges.len(), 1);
1058 assert!(!dag.has_cycle());
1059 }
1060
1061 #[test]
1062 fn test_dag_cycle_detection() {
1063 let mut dag = WorkflowDag::new();
1064 let a = dag
1065 .add_node(make_node("a"))
1066 .expect("should succeed in test");
1067 let b = dag
1068 .add_node(make_node("b"))
1069 .expect("should succeed in test");
1070 dag.add_edge(WorkflowEdge::new(a, b, "x"))
1071 .expect("should succeed in test");
1072 let result = dag.add_edge(WorkflowEdge::new(b, a, "x"));
1073 assert!(matches!(result, Err(DagError::CycleDetected)));
1074 }
1075
1076 #[test]
1077 fn test_dag_topological_sort() {
1078 let mut dag = WorkflowDag::new();
1079 let a = dag
1080 .add_node(make_node("a"))
1081 .expect("should succeed in test");
1082 let b = dag
1083 .add_node(make_node("b"))
1084 .expect("should succeed in test");
1085 let c = dag
1086 .add_node(make_node("c"))
1087 .expect("should succeed in test");
1088 dag.add_edge(WorkflowEdge::new(a, b, "x"))
1089 .expect("should succeed in test");
1090 dag.add_edge(WorkflowEdge::new(b, c, "x"))
1091 .expect("should succeed in test");
1092
1093 let order = dag.topological_sort().expect("should succeed in test");
1094 let pos_a = order
1095 .iter()
1096 .position(|&x| x == a)
1097 .expect("should succeed in test");
1098 let pos_b = order
1099 .iter()
1100 .position(|&x| x == b)
1101 .expect("should succeed in test");
1102 let pos_c = order
1103 .iter()
1104 .position(|&x| x == c)
1105 .expect("should succeed in test");
1106 assert!(pos_a < pos_b && pos_b < pos_c);
1107 }
1108
1109 #[test]
1110 fn test_dag_predecessors_successors() {
1111 let mut dag = WorkflowDag::new();
1112 let a = dag
1113 .add_node(make_node("a"))
1114 .expect("should succeed in test");
1115 let b = dag
1116 .add_node(make_node("b"))
1117 .expect("should succeed in test");
1118 let c = dag
1119 .add_node(make_node("c"))
1120 .expect("should succeed in test");
1121 dag.add_edge(WorkflowEdge::new(a, c, "x"))
1122 .expect("should succeed in test");
1123 dag.add_edge(WorkflowEdge::new(b, c, "x"))
1124 .expect("should succeed in test");
1125
1126 let preds = dag.predecessors(c);
1127 assert_eq!(preds.len(), 2);
1128 assert!(preds.contains(&a));
1129 assert!(preds.contains(&b));
1130
1131 let succs = dag.successors(a);
1132 assert_eq!(succs.len(), 1);
1133 assert_eq!(succs[0], c);
1134 }
1135
1136 #[test]
1139 fn test_engine_execute_no_executor() {
1140 let mut dag = WorkflowDag::new();
1141 let a = dag
1142 .add_node(make_node("a"))
1143 .expect("should succeed in test");
1144 let b = dag
1145 .add_node(make_node("b"))
1146 .expect("should succeed in test");
1147 dag.add_edge(WorkflowEdge::new(a, b, "x"))
1148 .expect("should succeed in test");
1149
1150 let engine = DagWorkflowEngine::new();
1151 let result = engine.execute(&mut dag).expect("should succeed in test");
1152 assert!(result.succeeded);
1153 assert_eq!(result.nodes_executed, 2);
1154 assert_eq!(result.nodes_failed, 0);
1155 }
1156
1157 #[test]
1158 fn test_engine_execute_with_executor() {
1159 let mut dag = WorkflowDag::new();
1160 let a = dag
1161 .add_node(make_node("a"))
1162 .expect("should succeed in test");
1163 let b = dag
1164 .add_node(make_node("b"))
1165 .expect("should succeed in test");
1166 dag.add_edge(WorkflowEdge::new(a, b, "x"))
1167 .expect("should succeed in test");
1168
1169 let engine = DagWorkflowEngine::new().with_executor(|node| {
1170 node.set_output("done", serde_json::json!(true));
1171 Ok(())
1172 });
1173
1174 let result = engine.execute(&mut dag).expect("should succeed in test");
1175 assert!(result.succeeded);
1176 assert_eq!(result.nodes_executed, 2);
1177 assert!(dag.nodes[&a].outputs.contains_key("done"));
1179 }
1180
1181 #[test]
1182 fn test_engine_node_failure() {
1183 let mut dag = WorkflowDag::new();
1184 let a = dag
1185 .add_node(make_node("failing"))
1186 .expect("should succeed in test");
1187 dag.add_node(make_node("b"))
1188 .expect("should succeed in test"); let engine = DagWorkflowEngine::new().with_executor(|node| {
1191 if node.task_type == "failing" {
1192 Err("intentional failure".to_string())
1193 } else {
1194 Ok(())
1195 }
1196 });
1197
1198 let result = engine.execute(&mut dag).expect("should succeed in test");
1199 assert!(!result.succeeded);
1200 assert!(result.nodes_failed > 0);
1201 assert!(matches!(result.node_statuses[&a], NodeStatus::Failed(_)));
1202 }
1203
1204 #[test]
1205 fn test_engine_node_status_accessor() {
1206 let mut dag = WorkflowDag::new();
1207 let a = dag
1208 .add_node(make_node("a"))
1209 .expect("should succeed in test");
1210
1211 let engine = DagWorkflowEngine::new();
1212 engine.execute(&mut dag).expect("should succeed in test");
1213
1214 assert_eq!(engine.node_status(a), Some(NodeStatus::Completed));
1215 assert_eq!(engine.node_status(NodeId::new()), None);
1216 }
1217
1218 #[test]
1221 fn test_template_instantiate_ingest_transcode() {
1222 let tmpl = ingest_transcode();
1223 assert_eq!(tmpl.name, "ingest_transcode");
1224 assert_eq!(tmpl.node_count(), 4);
1225
1226 let dag = tmpl
1227 .instantiate(&HashMap::new())
1228 .expect("should succeed in test");
1229 assert_eq!(dag.nodes.len(), 4);
1230 assert_eq!(dag.edges.len(), 3);
1231 assert!(!dag.has_cycle());
1232 }
1233
1234 #[test]
1235 fn test_template_instantiate_subtitle_burn() {
1236 let tmpl = subtitle_burn();
1237 let dag = tmpl
1238 .instantiate(&HashMap::new())
1239 .expect("should succeed in test");
1240 assert_eq!(dag.nodes.len(), 5);
1241 assert_eq!(dag.edges.len(), 4);
1242 assert!(!dag.has_cycle());
1243 }
1244
1245 #[test]
1246 fn test_template_instantiate_audio_normalize() {
1247 let tmpl = audio_normalize();
1248 let dag = tmpl
1249 .instantiate(&HashMap::new())
1250 .expect("should succeed in test");
1251 assert_eq!(dag.nodes.len(), 5);
1252 assert_eq!(dag.edges.len(), 4);
1253 assert!(!dag.has_cycle());
1254 }
1255
1256 #[test]
1257 fn test_template_parameter_override() {
1258 let tmpl = ingest_transcode();
1259 let mut overrides = HashMap::new();
1260 overrides.insert("preset".to_string(), serde_json::json!("ultrafast"));
1261
1262 let dag = tmpl
1263 .instantiate(&overrides)
1264 .expect("should succeed in test");
1265 for node in dag.nodes.values() {
1267 assert_eq!(node.parameters["preset"], serde_json::json!("ultrafast"));
1268 }
1269 }
1270
1271 #[test]
1272 fn test_template_default_parameters() {
1273 let tmpl = audio_normalize();
1274 assert_eq!(
1275 tmpl.default_parameters["target_lufs"],
1276 serde_json::json!(-23.0)
1277 );
1278 assert_eq!(
1279 tmpl.default_parameters["true_peak_dbtp"],
1280 serde_json::json!(-1.0)
1281 );
1282 }
1283
1284 #[test]
1285 fn test_dag_error_node_not_found() {
1286 let mut dag = WorkflowDag::new();
1287 let a = dag
1288 .add_node(make_node("a"))
1289 .expect("should succeed in test");
1290 let ghost = NodeId::new();
1291 let result = dag.add_edge(WorkflowEdge::new(a, ghost, "x"));
1292 assert!(matches!(result, Err(DagError::NodeNotFound(_))));
1293 }
1294
1295 #[test]
1298 fn test_branch_evaluator_equality() {
1299 let mut outputs = HashMap::new();
1300 outputs.insert("codec".to_string(), serde_json::json!("h264"));
1301
1302 assert_eq!(
1303 BranchEvaluator::evaluate_condition("codec == h264", &outputs),
1304 Some(true)
1305 );
1306 assert_eq!(
1307 BranchEvaluator::evaluate_condition("codec == vp9", &outputs),
1308 Some(false)
1309 );
1310 assert_eq!(
1311 BranchEvaluator::evaluate_condition("codec != vp9", &outputs),
1312 Some(true)
1313 );
1314 }
1315
1316 #[test]
1317 fn test_branch_evaluator_numeric_comparison() {
1318 let mut outputs = HashMap::new();
1319 outputs.insert("bitrate".to_string(), serde_json::json!(5000.0));
1320
1321 assert_eq!(
1322 BranchEvaluator::evaluate_condition("bitrate > 3000", &outputs),
1323 Some(true)
1324 );
1325 assert_eq!(
1326 BranchEvaluator::evaluate_condition("bitrate < 3000", &outputs),
1327 Some(false)
1328 );
1329 assert_eq!(
1330 BranchEvaluator::evaluate_condition("bitrate >= 5000", &outputs),
1331 Some(true)
1332 );
1333 assert_eq!(
1334 BranchEvaluator::evaluate_condition("bitrate <= 5000", &outputs),
1335 Some(true)
1336 );
1337 }
1338
1339 #[test]
1340 fn test_branch_evaluator_exists() {
1341 let mut outputs = HashMap::new();
1342 outputs.insert("result".to_string(), serde_json::json!(42));
1343
1344 assert_eq!(
1345 BranchEvaluator::evaluate_condition("result exists", &outputs),
1346 Some(true)
1347 );
1348 assert_eq!(
1349 BranchEvaluator::evaluate_condition("missing not_exists", &outputs),
1350 Some(true)
1351 );
1352 assert_eq!(
1353 BranchEvaluator::evaluate_condition("result not_exists", &outputs),
1354 Some(false)
1355 );
1356 }
1357
1358 #[test]
1359 fn test_branch_evaluator_boolean() {
1360 let mut outputs = HashMap::new();
1361 outputs.insert("success".to_string(), serde_json::json!(true));
1362
1363 assert_eq!(
1364 BranchEvaluator::evaluate_condition("success == true", &outputs),
1365 Some(true)
1366 );
1367 assert_eq!(
1368 BranchEvaluator::evaluate_condition("success == false", &outputs),
1369 Some(false)
1370 );
1371 }
1372
1373 #[test]
1374 fn test_branch_evaluator_numeric_equality() {
1375 let mut outputs = HashMap::new();
1376 outputs.insert("count".to_string(), serde_json::json!(42.0));
1377
1378 assert_eq!(
1379 BranchEvaluator::evaluate_condition("count == 42", &outputs),
1380 Some(true)
1381 );
1382 }
1383
1384 #[test]
1385 fn test_branch_evaluator_invalid_expression() {
1386 let outputs = HashMap::new();
1387 assert_eq!(BranchEvaluator::evaluate_condition("", &outputs), None);
1388 assert_eq!(
1389 BranchEvaluator::evaluate_condition("single", &outputs),
1390 None
1391 );
1392 }
1393
1394 #[test]
1395 fn test_resolve_branch_if_else_true() {
1396 let then_id = NodeId::new();
1397 let else_id = NodeId::new();
1398 let pred_id = NodeId::new();
1399 let branch_id = NodeId::new();
1400
1401 let branch = BranchNode {
1402 node_id: branch_id,
1403 predecessor: pred_id,
1404 branch_type: BranchType::IfElse {
1405 condition: "quality > 90".to_string(),
1406 then_branch: then_id,
1407 else_branch: else_id,
1408 },
1409 };
1410
1411 let mut outputs = HashMap::new();
1412 outputs.insert("quality".to_string(), serde_json::json!(95.0));
1413
1414 let result = BranchEvaluator::resolve_branch(&branch, &outputs);
1415 assert_eq!(result, vec![then_id]);
1416 }
1417
1418 #[test]
1419 fn test_resolve_branch_if_else_false() {
1420 let then_id = NodeId::new();
1421 let else_id = NodeId::new();
1422 let pred_id = NodeId::new();
1423 let branch_id = NodeId::new();
1424
1425 let branch = BranchNode {
1426 node_id: branch_id,
1427 predecessor: pred_id,
1428 branch_type: BranchType::IfElse {
1429 condition: "quality > 90".to_string(),
1430 then_branch: then_id,
1431 else_branch: else_id,
1432 },
1433 };
1434
1435 let mut outputs = HashMap::new();
1436 outputs.insert("quality".to_string(), serde_json::json!(50.0));
1437
1438 let result = BranchEvaluator::resolve_branch(&branch, &outputs);
1439 assert_eq!(result, vec![else_id]);
1440 }
1441
1442 #[test]
1443 fn test_resolve_branch_switch() {
1444 let av1_id = NodeId::new();
1445 let h264_id = NodeId::new();
1446 let default_id = NodeId::new();
1447 let pred_id = NodeId::new();
1448 let branch_id = NodeId::new();
1449
1450 let mut cases = HashMap::new();
1451 cases.insert("av1".to_string(), av1_id);
1452 cases.insert("h264".to_string(), h264_id);
1453
1454 let branch = BranchNode {
1455 node_id: branch_id,
1456 predecessor: pred_id,
1457 branch_type: BranchType::Switch {
1458 key: "codec".to_string(),
1459 cases,
1460 default: Some(default_id),
1461 },
1462 };
1463
1464 let mut outputs = HashMap::new();
1465 outputs.insert("codec".to_string(), serde_json::json!("av1"));
1466 assert_eq!(
1467 BranchEvaluator::resolve_branch(&branch, &outputs),
1468 vec![av1_id]
1469 );
1470
1471 outputs.insert("codec".to_string(), serde_json::json!("h264"));
1472 assert_eq!(
1473 BranchEvaluator::resolve_branch(&branch, &outputs),
1474 vec![h264_id]
1475 );
1476
1477 outputs.insert("codec".to_string(), serde_json::json!("vp9"));
1478 assert_eq!(
1479 BranchEvaluator::resolve_branch(&branch, &outputs),
1480 vec![default_id]
1481 );
1482 }
1483
1484 #[test]
1485 fn test_resolve_branch_switch_no_default() {
1486 let av1_id = NodeId::new();
1487 let pred_id = NodeId::new();
1488 let branch_id = NodeId::new();
1489
1490 let mut cases = HashMap::new();
1491 cases.insert("av1".to_string(), av1_id);
1492
1493 let branch = BranchNode {
1494 node_id: branch_id,
1495 predecessor: pred_id,
1496 branch_type: BranchType::Switch {
1497 key: "codec".to_string(),
1498 cases,
1499 default: None,
1500 },
1501 };
1502
1503 let mut outputs = HashMap::new();
1504 outputs.insert("codec".to_string(), serde_json::json!("vp9"));
1505 assert!(BranchEvaluator::resolve_branch(&branch, &outputs).is_empty());
1506 }
1507
1508 #[test]
1509 fn test_execute_with_branches_if_else() {
1510 let mut dag = WorkflowDag::new();
1514 let probe = dag.add_node(make_node("probe")).expect("add node");
1515 let decision = dag.add_node(make_node("branch")).expect("add node");
1516 let high = dag.add_node(make_node("high_res")).expect("add node");
1517 let low = dag.add_node(make_node("low_res")).expect("add node");
1518
1519 dag.add_edge(WorkflowEdge::new(probe, decision, "media_info"))
1520 .expect("add edge");
1521 dag.add_edge(WorkflowEdge::new(decision, high, "video"))
1522 .expect("add edge");
1523 dag.add_edge(WorkflowEdge::new(decision, low, "video"))
1524 .expect("add edge");
1525
1526 let branch = BranchNode {
1527 node_id: decision,
1528 predecessor: probe,
1529 branch_type: BranchType::IfElse {
1530 condition: "resolution > 1080".to_string(),
1531 then_branch: high,
1532 else_branch: low,
1533 },
1534 };
1535
1536 let mut branches = HashMap::new();
1537 branches.insert(decision, branch);
1538
1539 let result = dag
1541 .execute_with_branches(
1542 &branches,
1543 Some(&|node: &mut WorkflowNode| {
1544 if node.task_type == "probe" {
1545 node.set_output("resolution", serde_json::json!(4000.0));
1546 }
1547 Ok(())
1548 }),
1549 )
1550 .expect("execute");
1551
1552 assert!(result.succeeded);
1553 assert_eq!(
1554 result.node_statuses.get(&high),
1555 Some(&NodeStatus::Completed)
1556 );
1557 assert_eq!(result.node_statuses.get(&low), Some(&NodeStatus::Skipped));
1558 }
1559
1560 #[test]
1561 fn test_descendants() {
1562 let mut dag = WorkflowDag::new();
1563 let a = dag.add_node(make_node("a")).expect("add node");
1564 let b = dag.add_node(make_node("b")).expect("add node");
1565 let c = dag.add_node(make_node("c")).expect("add node");
1566 let d = dag.add_node(make_node("d")).expect("add node");
1567
1568 dag.add_edge(WorkflowEdge::new(a, b, "x")).expect("edge");
1569 dag.add_edge(WorkflowEdge::new(b, c, "x")).expect("edge");
1570 dag.add_edge(WorkflowEdge::new(b, d, "x")).expect("edge");
1571
1572 let desc = dag.descendants(a);
1573 assert_eq!(desc.len(), 3);
1574 assert!(desc.contains(&b));
1575 assert!(desc.contains(&c));
1576 assert!(desc.contains(&d));
1577
1578 let desc_b = dag.descendants(b);
1579 assert_eq!(desc_b.len(), 2);
1580 assert!(!desc_b.contains(&a));
1581 }
1582
1583 #[test]
1584 fn test_dag_error_duplicate_node() {
1585 let mut dag = WorkflowDag::new();
1586 let node = make_node("a");
1587 let id = node.node_id;
1588 dag.add_node(node).expect("should succeed in test");
1589 let dup = WorkflowNode {
1590 node_id: id,
1591 task_type: "b".to_string(),
1592 inputs: HashMap::new(),
1593 outputs: HashMap::new(),
1594 parameters: HashMap::new(),
1595 status: NodeStatus::Pending,
1596 };
1597 assert!(matches!(dag.add_node(dup), Err(DagError::DuplicateNode(_))));
1598 }
1599}