Skip to main content

oximedia_workflow/
workflow_diff.rs

1//! Workflow diff: compare two workflow versions to identify changes.
2//!
3//! Provides structural comparison of workflow definitions, identifying
4//! added, removed, and modified tasks and edges. Useful for version control,
5//! change auditing, and migration planning.
6
7use crate::task::{Task, TaskId};
8use crate::workflow::Workflow;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12/// Type of change detected between two workflow versions.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub enum ChangeType {
15    /// A new element was added.
16    Added,
17    /// An existing element was removed.
18    Removed,
19    /// An existing element was modified.
20    Modified,
21    /// No change.
22    Unchanged,
23}
24
25impl std::fmt::Display for ChangeType {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::Added => write!(f, "added"),
29            Self::Removed => write!(f, "removed"),
30            Self::Modified => write!(f, "modified"),
31            Self::Unchanged => write!(f, "unchanged"),
32        }
33    }
34}
35
36/// A change to a specific task.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TaskChange {
39    /// Task name used for identification.
40    pub task_name: String,
41    /// Type of change.
42    pub change_type: ChangeType,
43    /// Detailed field-level changes (only for Modified).
44    pub field_changes: Vec<FieldChange>,
45    /// Task from the old version (if present).
46    pub old_task_type: Option<String>,
47    /// Task from the new version (if present).
48    pub new_task_type: Option<String>,
49}
50
51/// A change to a specific field within a task.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct FieldChange {
54    /// Field name.
55    pub field: String,
56    /// Old value (as JSON string).
57    pub old_value: String,
58    /// New value (as JSON string).
59    pub new_value: String,
60}
61
62/// A change to an edge (dependency).
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct EdgeChange {
65    /// Source task name.
66    pub from_task: String,
67    /// Target task name.
68    pub to_task: String,
69    /// Type of change.
70    pub change_type: ChangeType,
71    /// Old condition (if any).
72    pub old_condition: Option<String>,
73    /// New condition (if any).
74    pub new_condition: Option<String>,
75}
76
77/// A change to workflow-level configuration.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConfigChange {
80    /// Config field name.
81    pub field: String,
82    /// Old value.
83    pub old_value: String,
84    /// New value.
85    pub new_value: String,
86}
87
88/// Complete diff result between two workflow versions.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct WorkflowDiff {
91    /// Name of the old workflow.
92    pub old_name: String,
93    /// Name of the new workflow.
94    pub new_name: String,
95    /// Whether the workflow name changed.
96    pub name_changed: bool,
97    /// Whether the description changed.
98    pub description_changed: bool,
99    /// Old description.
100    pub old_description: String,
101    /// New description.
102    pub new_description: String,
103    /// Task-level changes.
104    pub task_changes: Vec<TaskChange>,
105    /// Edge-level changes.
106    pub edge_changes: Vec<EdgeChange>,
107    /// Configuration changes.
108    pub config_changes: Vec<ConfigChange>,
109    /// Metadata changes.
110    pub metadata_changes: Vec<FieldChange>,
111    /// Summary statistics.
112    pub summary: DiffSummary,
113}
114
115/// Summary statistics of a diff.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct DiffSummary {
118    /// Number of tasks added.
119    pub tasks_added: usize,
120    /// Number of tasks removed.
121    pub tasks_removed: usize,
122    /// Number of tasks modified.
123    pub tasks_modified: usize,
124    /// Number of tasks unchanged.
125    pub tasks_unchanged: usize,
126    /// Number of edges added.
127    pub edges_added: usize,
128    /// Number of edges removed.
129    pub edges_removed: usize,
130    /// Number of edges modified.
131    pub edges_modified: usize,
132    /// Number of config fields changed.
133    pub config_changes: usize,
134    /// Whether there are any changes at all.
135    pub has_changes: bool,
136}
137
138/// Compute the diff between two workflow versions.
139///
140/// Tasks are matched by name (not by ID), since IDs are typically
141/// regenerated when importing/exporting workflows.
142#[must_use]
143pub fn diff_workflows(old: &Workflow, new: &Workflow) -> WorkflowDiff {
144    let task_changes = diff_tasks(old, new);
145    let edge_changes = diff_edges(old, new);
146    let config_changes = diff_config(old, new);
147    let metadata_changes = diff_metadata(old, new);
148
149    let tasks_added = task_changes
150        .iter()
151        .filter(|c| c.change_type == ChangeType::Added)
152        .count();
153    let tasks_removed = task_changes
154        .iter()
155        .filter(|c| c.change_type == ChangeType::Removed)
156        .count();
157    let tasks_modified = task_changes
158        .iter()
159        .filter(|c| c.change_type == ChangeType::Modified)
160        .count();
161    let tasks_unchanged = task_changes
162        .iter()
163        .filter(|c| c.change_type == ChangeType::Unchanged)
164        .count();
165    let edges_added = edge_changes
166        .iter()
167        .filter(|c| c.change_type == ChangeType::Added)
168        .count();
169    let edges_removed = edge_changes
170        .iter()
171        .filter(|c| c.change_type == ChangeType::Removed)
172        .count();
173    let edges_modified = edge_changes
174        .iter()
175        .filter(|c| c.change_type == ChangeType::Modified)
176        .count();
177
178    let has_changes = tasks_added > 0
179        || tasks_removed > 0
180        || tasks_modified > 0
181        || edges_added > 0
182        || edges_removed > 0
183        || edges_modified > 0
184        || !config_changes.is_empty()
185        || !metadata_changes.is_empty()
186        || old.name != new.name
187        || old.description != new.description;
188
189    WorkflowDiff {
190        old_name: old.name.clone(),
191        new_name: new.name.clone(),
192        name_changed: old.name != new.name,
193        description_changed: old.description != new.description,
194        old_description: old.description.clone(),
195        new_description: new.description.clone(),
196        task_changes,
197        edge_changes,
198        config_changes: config_changes.clone(),
199        metadata_changes,
200        summary: DiffSummary {
201            tasks_added,
202            tasks_removed,
203            tasks_modified,
204            tasks_unchanged,
205            edges_added,
206            edges_removed,
207            edges_modified,
208            config_changes: config_changes.len(),
209            has_changes,
210        },
211    }
212}
213
214/// Diff tasks between old and new workflows (matched by name).
215fn diff_tasks(old: &Workflow, new: &Workflow) -> Vec<TaskChange> {
216    let old_by_name: HashMap<&str, &Task> =
217        old.tasks.values().map(|t| (t.name.as_str(), t)).collect();
218    let new_by_name: HashMap<&str, &Task> =
219        new.tasks.values().map(|t| (t.name.as_str(), t)).collect();
220
221    let old_names: HashSet<&str> = old_by_name.keys().copied().collect();
222    let new_names: HashSet<&str> = new_by_name.keys().copied().collect();
223
224    let mut changes = Vec::new();
225
226    // Added tasks
227    for &name in new_names.difference(&old_names) {
228        let new_task = new_by_name[name];
229        changes.push(TaskChange {
230            task_name: name.to_string(),
231            change_type: ChangeType::Added,
232            field_changes: Vec::new(),
233            old_task_type: None,
234            new_task_type: Some(format!("{:?}", new_task.task_type)),
235        });
236    }
237
238    // Removed tasks
239    for &name in old_names.difference(&new_names) {
240        let old_task = old_by_name[name];
241        changes.push(TaskChange {
242            task_name: name.to_string(),
243            change_type: ChangeType::Removed,
244            field_changes: Vec::new(),
245            old_task_type: Some(format!("{:?}", old_task.task_type)),
246            new_task_type: None,
247        });
248    }
249
250    // Potentially modified tasks
251    for &name in old_names.intersection(&new_names) {
252        let old_task = old_by_name[name];
253        let new_task = new_by_name[name];
254        let field_changes = diff_task_fields(old_task, new_task);
255
256        let change_type = if field_changes.is_empty() {
257            ChangeType::Unchanged
258        } else {
259            ChangeType::Modified
260        };
261
262        changes.push(TaskChange {
263            task_name: name.to_string(),
264            change_type,
265            field_changes,
266            old_task_type: Some(format!("{:?}", old_task.task_type)),
267            new_task_type: Some(format!("{:?}", new_task.task_type)),
268        });
269    }
270
271    // Sort for deterministic output
272    changes.sort_by(|a, b| a.task_name.cmp(&b.task_name));
273    changes
274}
275
276/// Compare individual task fields.
277fn diff_task_fields(old: &Task, new: &Task) -> Vec<FieldChange> {
278    let mut changes = Vec::new();
279
280    let old_type_str = format!("{:?}", old.task_type);
281    let new_type_str = format!("{:?}", new.task_type);
282    if old_type_str != new_type_str {
283        changes.push(FieldChange {
284            field: "task_type".to_string(),
285            old_value: old_type_str,
286            new_value: new_type_str,
287        });
288    }
289
290    let old_priority = format!("{:?}", old.priority);
291    let new_priority = format!("{:?}", new.priority);
292    if old_priority != new_priority {
293        changes.push(FieldChange {
294            field: "priority".to_string(),
295            old_value: old_priority,
296            new_value: new_priority,
297        });
298    }
299
300    if old.timeout != new.timeout {
301        changes.push(FieldChange {
302            field: "timeout".to_string(),
303            old_value: format!("{:?}", old.timeout),
304            new_value: format!("{:?}", new.timeout),
305        });
306    }
307
308    if old.conditions != new.conditions {
309        changes.push(FieldChange {
310            field: "conditions".to_string(),
311            old_value: format!("{:?}", old.conditions),
312            new_value: format!("{:?}", new.conditions),
313        });
314    }
315
316    if old.metadata != new.metadata {
317        changes.push(FieldChange {
318            field: "metadata".to_string(),
319            old_value: format!("{:?}", old.metadata),
320            new_value: format!("{:?}", new.metadata),
321        });
322    }
323
324    changes
325}
326
327/// Diff edges between old and new workflows.
328///
329/// Edges are identified by (from_task_name, to_task_name).
330fn diff_edges(old: &Workflow, new: &Workflow) -> Vec<EdgeChange> {
331    let old_name_map: HashMap<TaskId, &str> = old
332        .tasks
333        .values()
334        .map(|t| (t.id, t.name.as_str()))
335        .collect();
336    let new_name_map: HashMap<TaskId, &str> = new
337        .tasks
338        .values()
339        .map(|t| (t.id, t.name.as_str()))
340        .collect();
341
342    // Build edge signature maps: (from_name, to_name) -> condition
343    let old_edges: HashMap<(String, String), Option<String>> = old
344        .edges
345        .iter()
346        .filter_map(|e| {
347            let from = old_name_map.get(&e.from)?.to_string();
348            let to = old_name_map.get(&e.to)?.to_string();
349            Some(((from, to), e.condition.clone()))
350        })
351        .collect();
352
353    let new_edges: HashMap<(String, String), Option<String>> = new
354        .edges
355        .iter()
356        .filter_map(|e| {
357            let from = new_name_map.get(&e.from)?.to_string();
358            let to = new_name_map.get(&e.to)?.to_string();
359            Some(((from, to), e.condition.clone()))
360        })
361        .collect();
362
363    let old_keys: HashSet<&(String, String)> = old_edges.keys().collect();
364    let new_keys: HashSet<&(String, String)> = new_edges.keys().collect();
365
366    let mut changes = Vec::new();
367
368    // Added edges
369    for &key in new_keys.difference(&old_keys) {
370        changes.push(EdgeChange {
371            from_task: key.0.clone(),
372            to_task: key.1.clone(),
373            change_type: ChangeType::Added,
374            old_condition: None,
375            new_condition: new_edges.get(key).cloned().flatten(),
376        });
377    }
378
379    // Removed edges
380    for &key in old_keys.difference(&new_keys) {
381        changes.push(EdgeChange {
382            from_task: key.0.clone(),
383            to_task: key.1.clone(),
384            change_type: ChangeType::Removed,
385            old_condition: old_edges.get(key).cloned().flatten(),
386            new_condition: None,
387        });
388    }
389
390    // Potentially modified edges (same endpoints, different condition)
391    for &key in old_keys.intersection(&new_keys) {
392        let old_cond = old_edges.get(key).cloned().flatten();
393        let new_cond = new_edges.get(key).cloned().flatten();
394        if old_cond != new_cond {
395            changes.push(EdgeChange {
396                from_task: key.0.clone(),
397                to_task: key.1.clone(),
398                change_type: ChangeType::Modified,
399                old_condition: old_cond,
400                new_condition: new_cond,
401            });
402        }
403    }
404
405    changes.sort_by(|a, b| (&a.from_task, &a.to_task).cmp(&(&b.from_task, &b.to_task)));
406    changes
407}
408
409/// Diff workflow-level config.
410fn diff_config(old: &Workflow, new: &Workflow) -> Vec<ConfigChange> {
411    let mut changes = Vec::new();
412
413    if old.config.max_concurrent_tasks != new.config.max_concurrent_tasks {
414        changes.push(ConfigChange {
415            field: "max_concurrent_tasks".to_string(),
416            old_value: old.config.max_concurrent_tasks.to_string(),
417            new_value: new.config.max_concurrent_tasks.to_string(),
418        });
419    }
420
421    if old.config.fail_fast != new.config.fail_fast {
422        changes.push(ConfigChange {
423            field: "fail_fast".to_string(),
424            old_value: old.config.fail_fast.to_string(),
425            new_value: new.config.fail_fast.to_string(),
426        });
427    }
428
429    if old.config.continue_on_error != new.config.continue_on_error {
430        changes.push(ConfigChange {
431            field: "continue_on_error".to_string(),
432            old_value: old.config.continue_on_error.to_string(),
433            new_value: new.config.continue_on_error.to_string(),
434        });
435    }
436
437    if old.config.global_timeout != new.config.global_timeout {
438        changes.push(ConfigChange {
439            field: "global_timeout".to_string(),
440            old_value: format!("{:?}", old.config.global_timeout),
441            new_value: format!("{:?}", new.config.global_timeout),
442        });
443    }
444
445    changes
446}
447
448/// Diff workflow metadata.
449fn diff_metadata(old: &Workflow, new: &Workflow) -> Vec<FieldChange> {
450    let mut changes = Vec::new();
451
452    let old_keys: HashSet<&String> = old.metadata.keys().collect();
453    let new_keys: HashSet<&String> = new.metadata.keys().collect();
454
455    for key in new_keys.difference(&old_keys) {
456        changes.push(FieldChange {
457            field: (*key).clone(),
458            old_value: String::new(),
459            new_value: new.metadata.get(*key).cloned().unwrap_or_default(),
460        });
461    }
462
463    for key in old_keys.difference(&new_keys) {
464        changes.push(FieldChange {
465            field: (*key).clone(),
466            old_value: old.metadata.get(*key).cloned().unwrap_or_default(),
467            new_value: String::new(),
468        });
469    }
470
471    for key in old_keys.intersection(&new_keys) {
472        let old_val = old.metadata.get(*key).cloned().unwrap_or_default();
473        let new_val = new.metadata.get(*key).cloned().unwrap_or_default();
474        if old_val != new_val {
475            changes.push(FieldChange {
476                field: (*key).clone(),
477                old_value: old_val,
478                new_value: new_val,
479            });
480        }
481    }
482
483    changes.sort_by(|a, b| a.field.cmp(&b.field));
484    changes
485}
486
487/// Generate a human-readable text summary of a diff.
488#[must_use]
489pub fn format_diff(diff: &WorkflowDiff) -> String {
490    let mut lines = Vec::new();
491    lines.push(format!(
492        "Workflow Diff: '{}' -> '{}'",
493        diff.old_name, diff.new_name
494    ));
495    lines.push(format!(
496        "Tasks: +{} -{} ~{} ={}\n",
497        diff.summary.tasks_added,
498        diff.summary.tasks_removed,
499        diff.summary.tasks_modified,
500        diff.summary.tasks_unchanged,
501    ));
502
503    if diff.name_changed {
504        lines.push(format!(
505            "  Name: '{}' -> '{}'",
506            diff.old_name, diff.new_name
507        ));
508    }
509    if diff.description_changed {
510        lines.push(format!(
511            "  Description: '{}' -> '{}'",
512            diff.old_description, diff.new_description
513        ));
514    }
515
516    for tc in &diff.task_changes {
517        if tc.change_type == ChangeType::Unchanged {
518            continue;
519        }
520        lines.push(format!("  Task '{}': {}", tc.task_name, tc.change_type));
521        for fc in &tc.field_changes {
522            lines.push(format!(
523                "    {}: '{}' -> '{}'",
524                fc.field, fc.old_value, fc.new_value
525            ));
526        }
527    }
528
529    for ec in &diff.edge_changes {
530        lines.push(format!(
531            "  Edge {} -> {}: {}",
532            ec.from_task, ec.to_task, ec.change_type
533        ));
534    }
535
536    for cc in &diff.config_changes {
537        lines.push(format!(
538            "  Config {}: '{}' -> '{}'",
539            cc.field, cc.old_value, cc.new_value
540        ));
541    }
542
543    lines.join("\n")
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::task::{TaskPriority, TaskType};
550    use std::time::Duration;
551
552    fn make_task(name: &str) -> Task {
553        Task::new(
554            name,
555            TaskType::Wait {
556                duration: Duration::from_secs(1),
557            },
558        )
559    }
560
561    fn make_simple_workflow() -> Workflow {
562        let mut wf = Workflow::new("test-workflow").with_description("A test workflow");
563        let t1 = wf.add_task(make_task("ingest"));
564        let t2 = wf.add_task(make_task("transcode"));
565        let t3 = wf.add_task(make_task("deliver"));
566        wf.add_edge(t1, t2).expect("edge");
567        wf.add_edge(t2, t3).expect("edge");
568        wf
569    }
570
571    #[test]
572    fn test_identical_workflows_no_changes() {
573        let wf1 = make_simple_workflow();
574        let wf2 = make_simple_workflow();
575
576        let diff = diff_workflows(&wf1, &wf2);
577        assert!(!diff.summary.has_changes);
578        assert_eq!(diff.summary.tasks_added, 0);
579        assert_eq!(diff.summary.tasks_removed, 0);
580        assert_eq!(diff.summary.tasks_modified, 0);
581        assert_eq!(diff.summary.tasks_unchanged, 3);
582    }
583
584    #[test]
585    fn test_added_task() {
586        let wf1 = make_simple_workflow();
587        let mut wf2 = make_simple_workflow();
588        wf2.add_task(make_task("qc_check"));
589
590        let diff = diff_workflows(&wf1, &wf2);
591        assert!(diff.summary.has_changes);
592        assert_eq!(diff.summary.tasks_added, 1);
593
594        let added = diff
595            .task_changes
596            .iter()
597            .find(|c| c.change_type == ChangeType::Added)
598            .expect("find added");
599        assert_eq!(added.task_name, "qc_check");
600    }
601
602    #[test]
603    fn test_removed_task() {
604        let mut wf1 = Workflow::new("old");
605        wf1.add_task(make_task("ingest"));
606        wf1.add_task(make_task("obsolete"));
607
608        let mut wf2 = Workflow::new("old");
609        wf2.add_task(make_task("ingest"));
610
611        let diff = diff_workflows(&wf1, &wf2);
612        assert_eq!(diff.summary.tasks_removed, 1);
613
614        let removed = diff
615            .task_changes
616            .iter()
617            .find(|c| c.change_type == ChangeType::Removed)
618            .expect("find removed");
619        assert_eq!(removed.task_name, "obsolete");
620    }
621
622    #[test]
623    fn test_modified_task_priority() {
624        let mut wf1 = Workflow::new("wf");
625        wf1.add_task(make_task("ingest"));
626
627        let mut wf2 = Workflow::new("wf");
628        wf2.add_task(make_task("ingest").with_priority(TaskPriority::High));
629
630        let diff = diff_workflows(&wf1, &wf2);
631        assert_eq!(diff.summary.tasks_modified, 1);
632
633        let modified = diff
634            .task_changes
635            .iter()
636            .find(|c| c.change_type == ChangeType::Modified)
637            .expect("find modified");
638        assert_eq!(modified.task_name, "ingest");
639        assert!(modified.field_changes.iter().any(|f| f.field == "priority"));
640    }
641
642    #[test]
643    fn test_added_edge() {
644        let mut wf1 = Workflow::new("wf");
645        let _t1 = wf1.add_task(make_task("a"));
646        let _t2 = wf1.add_task(make_task("b"));
647
648        let mut wf2 = Workflow::new("wf");
649        let t1b = wf2.add_task(make_task("a"));
650        let t2b = wf2.add_task(make_task("b"));
651        wf2.add_edge(t1b, t2b).expect("edge");
652
653        let diff = diff_workflows(&wf1, &wf2);
654        assert_eq!(diff.summary.edges_added, 1);
655    }
656
657    #[test]
658    fn test_removed_edge() {
659        let mut wf1 = Workflow::new("wf");
660        let t1 = wf1.add_task(make_task("a"));
661        let t2 = wf1.add_task(make_task("b"));
662        wf1.add_edge(t1, t2).expect("edge");
663
664        let mut wf2 = Workflow::new("wf");
665        wf2.add_task(make_task("a"));
666        wf2.add_task(make_task("b"));
667
668        let diff = diff_workflows(&wf1, &wf2);
669        assert_eq!(diff.summary.edges_removed, 1);
670    }
671
672    #[test]
673    fn test_modified_edge_condition() {
674        let mut wf1 = Workflow::new("wf");
675        let t1 = wf1.add_task(make_task("a"));
676        let t2 = wf1.add_task(make_task("b"));
677        wf1.add_edge(t1, t2).expect("edge");
678
679        let mut wf2 = Workflow::new("wf");
680        let t1b = wf2.add_task(make_task("a"));
681        let t2b = wf2.add_task(make_task("b"));
682        wf2.add_conditional_edge(t1b, t2b, "status == ok".to_string())
683            .expect("edge");
684
685        let diff = diff_workflows(&wf1, &wf2);
686        assert_eq!(diff.summary.edges_modified, 1);
687    }
688
689    #[test]
690    fn test_config_change() {
691        let mut wf1 = Workflow::new("wf");
692        wf1.config.max_concurrent_tasks = 4;
693
694        let mut wf2 = Workflow::new("wf");
695        wf2.config.max_concurrent_tasks = 8;
696
697        let diff = diff_workflows(&wf1, &wf2);
698        assert_eq!(diff.summary.config_changes, 1);
699        assert_eq!(diff.config_changes[0].field, "max_concurrent_tasks");
700    }
701
702    #[test]
703    fn test_name_change() {
704        let wf1 = Workflow::new("old-name");
705        let wf2 = Workflow::new("new-name");
706
707        let diff = diff_workflows(&wf1, &wf2);
708        assert!(diff.name_changed);
709        assert!(diff.summary.has_changes);
710    }
711
712    #[test]
713    fn test_description_change() {
714        let wf1 = Workflow::new("wf").with_description("old desc");
715        let wf2 = Workflow::new("wf").with_description("new desc");
716
717        let diff = diff_workflows(&wf1, &wf2);
718        assert!(diff.description_changed);
719    }
720
721    #[test]
722    fn test_metadata_change() {
723        let wf1 = Workflow::new("wf").with_metadata("env", "dev");
724        let wf2 = Workflow::new("wf").with_metadata("env", "prod");
725
726        let diff = diff_workflows(&wf1, &wf2);
727        assert!(!diff.metadata_changes.is_empty());
728    }
729
730    #[test]
731    fn test_metadata_added() {
732        let wf1 = Workflow::new("wf");
733        let wf2 = Workflow::new("wf").with_metadata("version", "2.0");
734
735        let diff = diff_workflows(&wf1, &wf2);
736        assert_eq!(diff.metadata_changes.len(), 1);
737        assert_eq!(diff.metadata_changes[0].field, "version");
738    }
739
740    #[test]
741    fn test_metadata_removed() {
742        let wf1 = Workflow::new("wf").with_metadata("version", "1.0");
743        let wf2 = Workflow::new("wf");
744
745        let diff = diff_workflows(&wf1, &wf2);
746        assert_eq!(diff.metadata_changes.len(), 1);
747    }
748
749    #[test]
750    fn test_format_diff_output() {
751        let wf1 = make_simple_workflow();
752        let mut wf2 = make_simple_workflow();
753        wf2.add_task(make_task("qc_check"));
754
755        let diff = diff_workflows(&wf1, &wf2);
756        let text = format_diff(&diff);
757
758        assert!(text.contains("Workflow Diff"));
759        assert!(text.contains("qc_check"));
760        assert!(text.contains("added"));
761    }
762
763    #[test]
764    fn test_empty_workflows_diff() {
765        let wf1 = Workflow::new("empty1");
766        let wf2 = Workflow::new("empty2");
767
768        let diff = diff_workflows(&wf1, &wf2);
769        assert!(diff.name_changed);
770        assert_eq!(diff.summary.tasks_added, 0);
771        assert_eq!(diff.summary.tasks_removed, 0);
772    }
773
774    #[test]
775    fn test_complex_diff_multiple_changes() {
776        let mut wf1 = Workflow::new("v1");
777        let t1 = wf1.add_task(make_task("ingest"));
778        let t2 = wf1.add_task(make_task("transcode"));
779        let t3 = wf1.add_task(make_task("old_step"));
780        wf1.add_edge(t1, t2).expect("edge");
781        wf1.add_edge(t2, t3).expect("edge");
782
783        let mut wf2 = Workflow::new("v2");
784        let t1b = wf2.add_task(make_task("ingest").with_priority(TaskPriority::High));
785        let t2b = wf2.add_task(make_task("transcode"));
786        let t4b = wf2.add_task(make_task("new_step"));
787        wf2.add_edge(t1b, t2b).expect("edge");
788        wf2.add_edge(t2b, t4b).expect("edge");
789
790        let diff = diff_workflows(&wf1, &wf2);
791        assert!(diff.summary.has_changes);
792        assert_eq!(diff.summary.tasks_added, 1); // new_step
793        assert_eq!(diff.summary.tasks_removed, 1); // old_step
794        assert_eq!(diff.summary.tasks_modified, 1); // ingest (priority changed)
795        assert_eq!(diff.summary.tasks_unchanged, 1); // transcode
796    }
797
798    #[test]
799    fn test_task_timeout_change() {
800        let mut wf1 = Workflow::new("wf");
801        wf1.add_task(make_task("t1"));
802
803        let mut wf2 = Workflow::new("wf");
804        let mut t = make_task("t1");
805        t.timeout = Duration::from_secs(7200);
806        wf2.add_task(t);
807
808        let diff = diff_workflows(&wf1, &wf2);
809        assert_eq!(diff.summary.tasks_modified, 1);
810        let modified = diff
811            .task_changes
812            .iter()
813            .find(|c| c.change_type == ChangeType::Modified)
814            .expect("find modified");
815        assert!(modified.field_changes.iter().any(|f| f.field == "timeout"));
816    }
817
818    #[test]
819    fn test_fail_fast_config_change() {
820        let mut wf1 = Workflow::new("wf");
821        wf1.config.fail_fast = false;
822
823        let mut wf2 = Workflow::new("wf");
824        wf2.config.fail_fast = true;
825
826        let diff = diff_workflows(&wf1, &wf2);
827        assert!(diff.config_changes.iter().any(|c| c.field == "fail_fast"));
828    }
829}