Skip to main content

oximedia_workflow/
dag.rs

1//! DAG-based workflow definition with `WorkflowNode`, `WorkflowEdge`, `WorkflowDag`,
2//! `WorkflowEngine` with node-level status tracking, and `WorkflowTemplate`.
3
4use serde::{Deserialize, Serialize};
5use std::cell::RefCell;
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::sync::{Arc, Mutex};
8use uuid::Uuid;
9
10// ---------------------------------------------------------------------------
11// WorkflowNode
12// ---------------------------------------------------------------------------
13
14/// Unique identifier for a workflow node.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct NodeId(Uuid);
17
18impl NodeId {
19    /// Create a new random node ID.
20    #[must_use]
21    pub fn new() -> Self {
22        Self(Uuid::new_v4())
23    }
24
25    /// Get underlying UUID.
26    #[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/// Execution status of a single workflow node.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub enum NodeStatus {
47    /// Not yet started.
48    Pending,
49    /// Waiting for dependencies.
50    Waiting,
51    /// Currently executing.
52    Running,
53    /// Completed successfully.
54    Completed,
55    /// Execution failed.
56    Failed(String),
57    /// Skipped (e.g., due to conditional edge).
58    Skipped,
59}
60
61impl NodeStatus {
62    /// Returns `true` if the node finished (completed, failed, or skipped).
63    #[must_use]
64    pub fn is_terminal(&self) -> bool {
65        matches!(self, Self::Completed | Self::Failed(_) | Self::Skipped)
66    }
67
68    /// Returns `true` if the node completed successfully.
69    #[must_use]
70    pub fn is_success(&self) -> bool {
71        matches!(self, Self::Completed)
72    }
73}
74
75/// A node inside a `WorkflowDag`.
76///
77/// Each node represents a single processing step with typed inputs, typed
78/// outputs, an opaque parameter bag, and an optional task-type tag.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct WorkflowNode {
81    /// Unique node identifier.
82    pub node_id: NodeId,
83    /// Human-readable task type / name.
84    pub task_type: String,
85    /// Named input ports and their current values (optional).
86    pub inputs: HashMap<String, serde_json::Value>,
87    /// Named output ports and their produced values (optional).
88    pub outputs: HashMap<String, serde_json::Value>,
89    /// Opaque parameter bag for task configuration.
90    pub parameters: HashMap<String, serde_json::Value>,
91    /// Current execution status.
92    pub status: NodeStatus,
93}
94
95impl WorkflowNode {
96    /// Create a new node with the given task type.
97    #[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    /// Attach an input value.
110    #[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    /// Attach a parameter.
117    #[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    /// Record an output produced by this node.
124    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// ---------------------------------------------------------------------------
130// WorkflowEdge
131// ---------------------------------------------------------------------------
132
133/// An edge connecting two nodes in a `WorkflowDag`.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct WorkflowEdge {
136    /// Source node.
137    pub from_node: NodeId,
138    /// Destination node.
139    pub to_node: NodeId,
140    /// Data type flowing along this edge (e.g. `"video/mp4"`, `"audio/pcm"`).
141    pub data_type: String,
142    /// Optional condition expression.
143    pub condition: Option<String>,
144}
145
146impl WorkflowEdge {
147    /// Create an edge without a condition.
148    #[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    /// Create a conditional edge.
159    #[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// ---------------------------------------------------------------------------
176// WorkflowDag
177// ---------------------------------------------------------------------------
178
179/// Error type for DAG operations.
180#[derive(Debug, thiserror::Error)]
181pub enum DagError {
182    /// Node not found in the DAG.
183    #[error("Node not found: {0}")]
184    NodeNotFound(NodeId),
185
186    /// A cycle was detected in the DAG.
187    #[error("Cycle detected in DAG")]
188    CycleDetected,
189
190    /// Duplicate node.
191    #[error("Node already exists: {0}")]
192    DuplicateNode(NodeId),
193}
194
195/// A directed acyclic graph of `WorkflowNode`s connected by `WorkflowEdge`s.
196///
197/// Provides cycle detection and Kahn's-algorithm topological sort.
198///
199/// The topological sort result is cached after the first computation and
200/// invalidated automatically whenever the graph topology changes (node or
201/// edge mutations).
202#[derive(Debug, Serialize, Deserialize)]
203pub struct WorkflowDag {
204    /// Nodes, keyed by their ID.
205    pub nodes: HashMap<NodeId, WorkflowNode>,
206    /// Edges in insertion order.
207    pub edges: Vec<WorkflowEdge>,
208    /// Cached topological order. Invalidated on any mutation.
209    /// `#[serde(skip)]` keeps this out of the serialized form.
210    #[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            // The cache is NOT propagated to the clone; the clone must recompute.
220            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    /// Create an empty DAG.
237    #[must_use]
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Invalidate the topology cache. Must be called after any structural mutation.
243    fn invalidate_topo_cache(&self) {
244        *self.topo_cache.borrow_mut() = None;
245    }
246
247    /// Add a node. Returns an error if the node ID already exists.
248    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    /// Add an edge. Both nodes must already exist.
259    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        // Eagerly detect cycles.
269        if self.has_cycle() {
270            self.edges.pop();
271            return Err(DagError::CycleDetected);
272        }
273        self.invalidate_topo_cache();
274        Ok(())
275    }
276
277    /// Returns `true` if the DAG contains a cycle (DFS-based).
278    #[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    /// Topological sort using Kahn's algorithm.
314    ///
315    /// Returns nodes in an order where every dependency appears before its
316    /// dependents. The result is cached after the first call and reused on
317    /// subsequent calls until the graph topology changes.
318    pub fn topological_sort(&self) -> Result<Vec<NodeId>, DagError> {
319        // Return cached result if valid.
320        {
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        // Build in-degree map.
332        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)| 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        // Store result in cache.
358        *self.topo_cache.borrow_mut() = Some(result.clone());
359
360        Ok(result)
361    }
362
363    /// Return immediate predecessors of `node_id`.
364    #[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    /// Return immediate successors of `node_id`.
374    #[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// ---------------------------------------------------------------------------
385// DagWorkflowEngine
386// ---------------------------------------------------------------------------
387
388/// Per-run node status snapshot produced by `DagWorkflowEngine::execute`.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct DagRunStatus {
391    /// Node statuses at the end of the run.
392    pub node_statuses: HashMap<NodeId, NodeStatus>,
393    /// Whether the overall run succeeded.
394    pub succeeded: bool,
395    /// Total number of nodes executed.
396    pub nodes_executed: usize,
397    /// Total number of nodes that failed.
398    pub nodes_failed: usize,
399}
400
401/// Lightweight DAG workflow engine.
402///
403/// Executes nodes in topological order.  For each node it calls the registered
404/// `executor` closure (if any) and tracks per-node status.
405pub struct DagWorkflowEngine {
406    /// Node executor: receives the mutable node and returns Ok or error string.
407    executor: Option<Arc<dyn Fn(&mut WorkflowNode) -> Result<(), String> + Send + Sync>>,
408    /// Status registry shared across callers.
409    statuses: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
410}
411
412impl DagWorkflowEngine {
413    /// Create an engine without an executor (nodes are marked Completed immediately).
414    #[must_use]
415    pub fn new() -> Self {
416        Self {
417            executor: None,
418            statuses: Arc::new(Mutex::new(HashMap::new())),
419        }
420    }
421
422    /// Attach a node executor closure.
423    #[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    /// Execute a DAG and return a status snapshot.
433    ///
434    /// # Errors
435    ///
436    /// Returns a `DagError` if the DAG contains a cycle.
437    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        // Persist in shared registry.
473        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    /// Get the current status of a node (across all runs).
488    #[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// ---------------------------------------------------------------------------
501// WorkflowTemplate
502// ---------------------------------------------------------------------------
503
504/// A named, reusable workflow configuration that can be instantiated into a
505/// `WorkflowDag`.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct WorkflowTemplate {
508    /// Unique template name.
509    pub name: String,
510    /// Human-readable description.
511    pub description: String,
512    /// Default parameters shared by all nodes.
513    pub default_parameters: HashMap<String, serde_json::Value>,
514    /// Pre-built node definitions (without IDs – IDs are assigned on instantiation).
515    node_specs: Vec<NodeSpec>,
516    /// Edge definitions as `(from-index, to-index, data_type)`.
517    edge_specs: Vec<(usize, usize, String)>,
518}
519
520/// Lightweight spec used inside a `WorkflowTemplate`.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522struct NodeSpec {
523    task_type: String,
524    parameters: HashMap<String, serde_json::Value>,
525}
526
527impl WorkflowTemplate {
528    /// Create a new empty template.
529    #[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    /// Add a default parameter.
541    #[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    /// Append a node spec; returns the spec index for use in `add_edge`.
552    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    /// Append an edge spec between two node indices.
565    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    /// Instantiate the template into a fresh `WorkflowDag`.
570    ///
571    /// `overrides` can supply parameter overrides (merged with defaults).
572    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    /// Return the number of node specs.
605    #[must_use]
606    pub fn node_count(&self) -> usize {
607        self.node_specs.len()
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Conditional branching
613// ---------------------------------------------------------------------------
614
615/// Type of branch node for conditional execution paths.
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub enum BranchType {
618    /// If-else: evaluate condition on predecessor output, choose `then_branch`
619    /// or `else_branch` node.
620    IfElse {
621        /// Condition expression evaluated against the predecessor's outputs.
622        condition: String,
623        /// Node to execute when condition is true.
624        then_branch: NodeId,
625        /// Node to execute when condition is false.
626        else_branch: NodeId,
627    },
628    /// Switch-case: match a key against multiple values, each mapping to a
629    /// different successor node.
630    Switch {
631        /// The output key whose value is inspected.
632        key: String,
633        /// Mapping from expected string value to target node.
634        cases: HashMap<String, NodeId>,
635        /// Fallback node if no case matches.
636        default: Option<NodeId>,
637    },
638}
639
640/// A branch node that selects which successor(s) to execute.
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct BranchNode {
643    /// The node ID of this branch decision point.
644    pub node_id: NodeId,
645    /// Which predecessor's output to evaluate.
646    pub predecessor: NodeId,
647    /// Branch logic.
648    pub branch_type: BranchType,
649}
650
651/// Evaluator for branch conditions.
652pub struct BranchEvaluator;
653
654impl BranchEvaluator {
655    /// Evaluate a simple condition expression against a set of outputs.
656    ///
657    /// Supported expressions:
658    /// - `key == value`  (string equality)
659    /// - `key != value`
660    /// - `key > number`  (numeric comparison)
661    /// - `key >= number`
662    /// - `key < number`
663    /// - `key <= number`
664    /// - `key exists`    (key is present)
665    /// - `key not_exists` (key is absent)
666    ///
667    /// Returns `None` if the expression cannot be parsed.
668    #[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        // Unary operators
681        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    /// Resolve which node(s) should be activated based on a branch node
728    /// and the predecessor's outputs.
729    #[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    /// Execute the DAG with branch support.
771    ///
772    /// For each branch node found (identified by node IDs in `branches`),
773    /// only the selected successor path is activated; the other paths'
774    /// nodes are marked `Skipped`.
775    ///
776    /// # Errors
777    ///
778    /// Returns `DagError` if the DAG contains a cycle.
779    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            // Skip nodes that have been excluded by branch decisions
793            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                    // Check if this node is a branch decision point
821                    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                        // Mark non-selected successors for skipping
831                        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    /// Collect all transitive descendants of a node.
862    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    /// Return all transitive descendants of the given node.
876    #[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// ---------------------------------------------------------------------------
885// Pre-built templates
886// ---------------------------------------------------------------------------
887
888/// Pre-built template: ingest and transcode.
889///
890/// Nodes: ingest → probe → transcode → package
891#[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/// Pre-built template: burn subtitles into video.
913///
914/// Nodes: ingest → `subtitle_parse` → `burn_subtitles` → encode → deliver
915#[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/// Pre-built template: audio normalization.
939///
940/// Nodes: ingest → `audio_analyze` → normalize → encode → deliver
941#[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// ---------------------------------------------------------------------------
965// Tests
966// ---------------------------------------------------------------------------
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    fn make_node(task_type: &str) -> WorkflowNode {
973        WorkflowNode::new(task_type)
974    }
975
976    // --- WorkflowNode ---
977
978    #[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    // --- WorkflowEdge ---
1022
1023    #[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    // --- WorkflowDag ---
1043
1044    #[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    // --- DagWorkflowEngine ---
1137
1138    #[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        // Outputs were set.
1178        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"); // isolated node
1189
1190        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    // --- WorkflowTemplate ---
1219
1220    #[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        // Every node should have the overridden preset.
1266        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    // --- BranchEvaluator ---
1296
1297    #[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        // Build DAG: probe -> branch_decision
1511        //   branch_decision -> high_res_encode
1512        //   branch_decision -> low_res_encode
1513        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        // Executor that sets probe output to resolution=4000
1540        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}