1use crate::task::{Task, TaskId};
8use crate::workflow::Workflow;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub enum ChangeType {
15 Added,
17 Removed,
19 Modified,
21 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#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TaskChange {
39 pub task_name: String,
41 pub change_type: ChangeType,
43 pub field_changes: Vec<FieldChange>,
45 pub old_task_type: Option<String>,
47 pub new_task_type: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct FieldChange {
54 pub field: String,
56 pub old_value: String,
58 pub new_value: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct EdgeChange {
65 pub from_task: String,
67 pub to_task: String,
69 pub change_type: ChangeType,
71 pub old_condition: Option<String>,
73 pub new_condition: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConfigChange {
80 pub field: String,
82 pub old_value: String,
84 pub new_value: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct WorkflowDiff {
91 pub old_name: String,
93 pub new_name: String,
95 pub name_changed: bool,
97 pub description_changed: bool,
99 pub old_description: String,
101 pub new_description: String,
103 pub task_changes: Vec<TaskChange>,
105 pub edge_changes: Vec<EdgeChange>,
107 pub config_changes: Vec<ConfigChange>,
109 pub metadata_changes: Vec<FieldChange>,
111 pub summary: DiffSummary,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct DiffSummary {
118 pub tasks_added: usize,
120 pub tasks_removed: usize,
122 pub tasks_modified: usize,
124 pub tasks_unchanged: usize,
126 pub edges_added: usize,
128 pub edges_removed: usize,
130 pub edges_modified: usize,
132 pub config_changes: usize,
134 pub has_changes: bool,
136}
137
138#[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
214fn 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 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 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 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 changes.sort_by(|a, b| a.task_name.cmp(&b.task_name));
273 changes
274}
275
276fn 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
327fn 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 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 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 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 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
409fn 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
448fn 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#[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); assert_eq!(diff.summary.tasks_removed, 1); assert_eq!(diff.summary.tasks_modified, 1); assert_eq!(diff.summary.tasks_unchanged, 1); }
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}