1use crate::{Workflow, WorkflowId};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[cfg(feature = "openapi")]
12use utoipa::ToSchema;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(ToSchema))]
17pub struct WorkflowVersionEntry {
18 pub version: String,
20
21 #[cfg_attr(feature = "openapi", schema(value_type = String))]
23 pub workflow_id: WorkflowId,
24
25 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
27 pub parent_id: Option<WorkflowId>,
28
29 pub author: String,
31
32 pub created_at: DateTime<Utc>,
34
35 pub change_description: String,
37
38 pub change_type: ChangeType,
40
41 #[serde(default)]
43 pub tags: Vec<String>,
44
45 #[serde(default)]
47 pub published: bool,
48
49 #[serde(default)]
51 pub changelog: Vec<ChangelogEntry>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(ToSchema))]
57pub enum ChangeType {
58 Major,
60 Minor,
62 Patch,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "openapi", derive(ToSchema))]
69pub struct ChangelogEntry {
70 pub entry_type: ChangelogType,
72
73 pub description: String,
75
76 #[serde(default)]
78 pub affected_nodes: Vec<String>,
79
80 #[serde(default)]
82 pub breaking: bool,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "openapi", derive(ToSchema))]
88pub enum ChangelogType {
89 Added,
91 Changed,
93 Deprecated,
95 Removed,
97 Fixed,
99 Security,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "openapi", derive(ToSchema))]
106pub struct WorkflowVersionHistory {
107 pub workflow_name: String,
109
110 pub versions: Vec<WorkflowVersionEntry>,
112
113 #[serde(default)]
115 pub aliases: HashMap<String, String>,
116}
117
118impl WorkflowVersionHistory {
119 pub fn new(workflow_name: String) -> Self {
121 Self {
122 workflow_name,
123 versions: Vec::new(),
124 aliases: HashMap::new(),
125 }
126 }
127
128 pub fn add_version(&mut self, entry: WorkflowVersionEntry) {
130 self.versions.push(entry);
131 self.sort_versions();
132 }
133
134 fn sort_versions(&mut self) {
136 self.versions.sort_by(|a, b| {
137 let a_parts = parse_version(&a.version).unwrap_or((0, 0, 0));
138 let b_parts = parse_version(&b.version).unwrap_or((0, 0, 0));
139 a_parts.cmp(&b_parts)
140 });
141 }
142
143 pub fn latest_version(&self) -> Option<&WorkflowVersionEntry> {
145 self.versions.last()
146 }
147
148 pub fn get_version(&self, version: &str) -> Option<&WorkflowVersionEntry> {
150 let resolved_version = self
152 .aliases
153 .get(version)
154 .map(|s| s.as_str())
155 .unwrap_or(version);
156
157 self.versions.iter().find(|v| v.version == resolved_version)
158 }
159
160 pub fn published_versions(&self) -> Vec<&WorkflowVersionEntry> {
162 self.versions.iter().filter(|v| v.published).collect()
163 }
164
165 pub fn get_history_between(&self, from: &str, to: &str) -> Vec<&WorkflowVersionEntry> {
167 let from_idx = self.versions.iter().position(|v| v.version == from);
168 let to_idx = self.versions.iter().position(|v| v.version == to);
169
170 match (from_idx, to_idx) {
171 (Some(from), Some(to)) if from < to => self.versions[from + 1..=to].iter().collect(),
172 _ => Vec::new(),
173 }
174 }
175
176 pub fn set_alias(&mut self, alias: String, version: String) {
178 self.aliases.insert(alias, version);
179 }
180
181 pub fn breaking_changes_since(&self, version: &str) -> Vec<&ChangelogEntry> {
183 let from_idx = self.versions.iter().position(|v| v.version == version);
184
185 match from_idx {
186 Some(idx) => self.versions[idx + 1..]
187 .iter()
188 .flat_map(|v| &v.changelog)
189 .filter(|e| e.breaking)
190 .collect(),
191 None => Vec::new(),
192 }
193 }
194
195 pub fn requires_migration(&self, from: &str, to: &str) -> bool {
197 let from_parts = parse_version(from).unwrap_or((0, 0, 0));
198 let to_parts = parse_version(to).unwrap_or((0, 0, 0));
199
200 from_parts.0 != to_parts.0
202 }
203}
204
205#[derive(Debug)]
207pub struct VersionCompatibility {
208 pub from_version: String,
210
211 pub to_version: String,
213
214 pub compatible: bool,
216
217 pub requires_migration: bool,
219
220 pub issues: Vec<String>,
222
223 pub breaking_changes: Vec<String>,
225}
226
227impl VersionCompatibility {
228 pub fn check(from: &str, to: &str, history: &WorkflowVersionHistory) -> Self {
230 let from_parts = parse_version(from).unwrap_or((0, 0, 0));
231 let to_parts = parse_version(to).unwrap_or((0, 0, 0));
232
233 let mut issues = Vec::new();
234 let mut breaking_changes = Vec::new();
235
236 let major_diff = to_parts.0 as i32 - from_parts.0 as i32;
238 let requires_migration = major_diff != 0;
239
240 let compatible = if major_diff < 0 {
242 issues.push(format!(
243 "Downgrading major version from {} to {} is not supported",
244 from, to
245 ));
246 false
247 } else if major_diff > 1 {
248 issues.push(format!(
249 "Skipping major versions (from {} to {}) may have issues",
250 from, to
251 ));
252 true
253 } else {
254 true
255 };
256
257 for entry in history.breaking_changes_since(from) {
259 breaking_changes.push(entry.description.clone());
260 }
261
262 Self {
263 from_version: from.to_string(),
264 to_version: to.to_string(),
265 compatible,
266 requires_migration,
267 issues,
268 breaking_changes,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "openapi", derive(ToSchema))]
276pub struct WorkflowDiff {
277 pub from_version: String,
279
280 pub to_version: String,
282
283 #[serde(default)]
285 pub nodes_added: Vec<String>,
286
287 #[serde(default)]
289 pub nodes_removed: Vec<String>,
290
291 #[serde(default)]
293 pub nodes_modified: Vec<NodeChange>,
294
295 #[serde(default)]
297 pub edges_added: Vec<EdgeInfo>,
298
299 #[serde(default)]
301 pub edges_removed: Vec<EdgeInfo>,
302
303 #[serde(default)]
305 pub metadata_changes: Vec<MetadataChange>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310#[cfg_attr(feature = "openapi", derive(ToSchema))]
311pub struct NodeChange {
312 pub node_id: String,
314
315 pub node_name: String,
317
318 pub changes: Vec<String>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324#[cfg_attr(feature = "openapi", derive(ToSchema))]
325pub struct EdgeInfo {
326 pub from: String,
328
329 pub to: String,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335#[cfg_attr(feature = "openapi", derive(ToSchema))]
336pub struct MetadataChange {
337 pub field: String,
339
340 pub old_value: Option<String>,
342
343 pub new_value: Option<String>,
345}
346
347impl WorkflowDiff {
348 pub fn generate(from: &Workflow, to: &Workflow) -> Self {
350 let mut diff = Self {
351 from_version: from.metadata.version.clone(),
352 to_version: to.metadata.version.clone(),
353 nodes_added: Vec::new(),
354 nodes_removed: Vec::new(),
355 nodes_modified: Vec::new(),
356 edges_added: Vec::new(),
357 edges_removed: Vec::new(),
358 metadata_changes: Vec::new(),
359 };
360
361 let from_node_ids: HashMap<_, _> = from.nodes.iter().map(|n| (n.id, n)).collect();
363 let to_node_ids: HashMap<_, _> = to.nodes.iter().map(|n| (n.id, n)).collect();
364
365 for (id, node) in &to_node_ids {
367 if !from_node_ids.contains_key(id) {
368 diff.nodes_added.push(node.name.clone());
369 }
370 }
371
372 for (id, node) in &from_node_ids {
374 if !to_node_ids.contains_key(id) {
375 diff.nodes_removed.push(node.name.clone());
376 }
377 }
378
379 for (id, from_node) in &from_node_ids {
381 if let Some(to_node) = to_node_ids.get(id) {
382 let mut changes = Vec::new();
383
384 if from_node.name != to_node.name {
385 changes.push(format!("name: '{}' -> '{}'", from_node.name, to_node.name));
386 }
387
388 if format!("{:?}", from_node.kind) != format!("{:?}", to_node.kind) {
389 changes.push(format!(
390 "kind: '{:?}' -> '{:?}'",
391 from_node.kind, to_node.kind
392 ));
393 }
394
395 if !changes.is_empty() {
396 diff.nodes_modified.push(NodeChange {
397 node_id: id.to_string(),
398 node_name: to_node.name.clone(),
399 changes,
400 });
401 }
402 }
403 }
404
405 let from_edges: Vec<_> = from
407 .edges
408 .iter()
409 .map(|e| (e.from.to_string(), e.to.to_string()))
410 .collect();
411 let to_edges: Vec<_> = to
412 .edges
413 .iter()
414 .map(|e| (e.from.to_string(), e.to.to_string()))
415 .collect();
416
417 for (from_id, to_id) in &to_edges {
418 if !from_edges.contains(&(from_id.clone(), to_id.clone())) {
419 diff.edges_added.push(EdgeInfo {
420 from: from_id.clone(),
421 to: to_id.clone(),
422 });
423 }
424 }
425
426 for (from_id, to_id) in &from_edges {
427 if !to_edges.contains(&(from_id.clone(), to_id.clone())) {
428 diff.edges_removed.push(EdgeInfo {
429 from: from_id.clone(),
430 to: to_id.clone(),
431 });
432 }
433 }
434
435 if from.metadata.name != to.metadata.name {
437 diff.metadata_changes.push(MetadataChange {
438 field: "name".to_string(),
439 old_value: Some(from.metadata.name.clone()),
440 new_value: Some(to.metadata.name.clone()),
441 });
442 }
443
444 if from.metadata.description != to.metadata.description {
445 diff.metadata_changes.push(MetadataChange {
446 field: "description".to_string(),
447 old_value: from.metadata.description.clone(),
448 new_value: to.metadata.description.clone(),
449 });
450 }
451
452 diff
453 }
454
455 pub fn has_changes(&self) -> bool {
457 !self.nodes_added.is_empty()
458 || !self.nodes_removed.is_empty()
459 || !self.nodes_modified.is_empty()
460 || !self.edges_added.is_empty()
461 || !self.edges_removed.is_empty()
462 || !self.metadata_changes.is_empty()
463 }
464
465 pub fn summary(&self) -> String {
467 let mut lines = Vec::new();
468
469 lines.push(format!(
470 "Diff from version {} to {}",
471 self.from_version, self.to_version
472 ));
473
474 if !self.nodes_added.is_empty() {
475 lines.push(format!(
476 "Added {} nodes: {:?}",
477 self.nodes_added.len(),
478 self.nodes_added
479 ));
480 }
481
482 if !self.nodes_removed.is_empty() {
483 lines.push(format!(
484 "Removed {} nodes: {:?}",
485 self.nodes_removed.len(),
486 self.nodes_removed
487 ));
488 }
489
490 if !self.nodes_modified.is_empty() {
491 lines.push(format!("Modified {} nodes", self.nodes_modified.len()));
492 }
493
494 if !self.edges_added.is_empty() {
495 lines.push(format!("Added {} edges", self.edges_added.len()));
496 }
497
498 if !self.edges_removed.is_empty() {
499 lines.push(format!("Removed {} edges", self.edges_removed.len()));
500 }
501
502 if !self.metadata_changes.is_empty() {
503 lines.push(format!(
504 "Changed {} metadata fields",
505 self.metadata_changes.len()
506 ));
507 }
508
509 if !self.has_changes() {
510 lines.push("No changes detected".to_string());
511 }
512
513 lines.join("\n")
514 }
515}
516
517fn parse_version(version: &str) -> Result<(u32, u32, u32), String> {
519 let parts: Vec<&str> = version.split('.').collect();
520 if parts.len() != 3 {
521 return Err(format!("Invalid version format: {}", version));
522 }
523
524 let major = parts[0]
525 .parse::<u32>()
526 .map_err(|_| format!("Invalid major version: {}", parts[0]))?;
527 let minor = parts[1]
528 .parse::<u32>()
529 .map_err(|_| format!("Invalid minor version: {}", parts[1]))?;
530 let patch = parts[2]
531 .parse::<u32>()
532 .map_err(|_| format!("Invalid patch version: {}", parts[2]))?;
533
534 Ok((major, minor, patch))
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::{Edge, Node, NodeKind};
541
542 #[test]
543 fn test_version_history_creation() {
544 let history = WorkflowVersionHistory::new("My Workflow".to_string());
545 assert_eq!(history.workflow_name, "My Workflow");
546 assert!(history.versions.is_empty());
547 assert!(history.aliases.is_empty());
548 }
549
550 #[test]
551 fn test_add_version_to_history() {
552 let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
553
554 let entry = WorkflowVersionEntry {
555 version: "1.0.0".to_string(),
556 workflow_id: uuid::Uuid::new_v4(),
557 parent_id: None,
558 author: "Alice".to_string(),
559 created_at: Utc::now(),
560 change_description: "Initial version".to_string(),
561 change_type: ChangeType::Major,
562 tags: vec!["stable".to_string()],
563 published: true,
564 changelog: vec![],
565 };
566
567 history.add_version(entry);
568 assert_eq!(history.versions.len(), 1);
569 assert_eq!(history.latest_version().unwrap().version, "1.0.0");
570 }
571
572 #[test]
573 fn test_version_sorting() {
574 let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
575
576 for version in ["1.2.0", "1.0.0", "2.0.0", "1.1.0"] {
578 let entry = WorkflowVersionEntry {
579 version: version.to_string(),
580 workflow_id: uuid::Uuid::new_v4(),
581 parent_id: None,
582 author: "Alice".to_string(),
583 created_at: Utc::now(),
584 change_description: "Test".to_string(),
585 change_type: ChangeType::Minor,
586 tags: vec![],
587 published: true,
588 changelog: vec![],
589 };
590 history.add_version(entry);
591 }
592
593 assert_eq!(history.versions[0].version, "1.0.0");
595 assert_eq!(history.versions[1].version, "1.1.0");
596 assert_eq!(history.versions[2].version, "1.2.0");
597 assert_eq!(history.versions[3].version, "2.0.0");
598 }
599
600 #[test]
601 fn test_version_aliases() {
602 let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
603
604 let entry = WorkflowVersionEntry {
605 version: "1.0.0".to_string(),
606 workflow_id: uuid::Uuid::new_v4(),
607 parent_id: None,
608 author: "Alice".to_string(),
609 created_at: Utc::now(),
610 change_description: "Initial".to_string(),
611 change_type: ChangeType::Major,
612 tags: vec![],
613 published: true,
614 changelog: vec![],
615 };
616 history.add_version(entry);
617
618 history.set_alias("stable".to_string(), "1.0.0".to_string());
619
620 let version = history.get_version("stable");
621 assert!(version.is_some());
622 assert_eq!(version.unwrap().version, "1.0.0");
623 }
624
625 #[test]
626 fn test_version_compatibility_check() {
627 let history = WorkflowVersionHistory::new("My Workflow".to_string());
628
629 let compat = VersionCompatibility::check("1.0.0", "1.1.0", &history);
630 assert!(compat.compatible);
631 assert!(!compat.requires_migration);
632
633 let compat = VersionCompatibility::check("1.0.0", "2.0.0", &history);
634 assert!(compat.compatible);
635 assert!(compat.requires_migration);
636
637 let compat = VersionCompatibility::check("2.0.0", "1.0.0", &history);
638 assert!(!compat.compatible);
639 }
640
641 #[test]
642 fn test_workflow_diff_generation() {
643 let mut workflow_v1 = Workflow::new("Test Workflow".to_string());
644 workflow_v1.metadata.version = "1.0.0".to_string();
645
646 let start_node = Node::new("Start".to_string(), NodeKind::Start);
647 let start_id = start_node.id;
648 workflow_v1.add_node(start_node);
649
650 let end_node = Node::new("End".to_string(), NodeKind::End);
651 let end_id = end_node.id;
652 workflow_v1.add_node(end_node);
653
654 workflow_v1.add_edge(Edge::new(start_id, end_id));
655
656 let mut workflow_v2 = workflow_v1.clone();
658 workflow_v2.metadata.version = "1.1.0".to_string();
659
660 let process_node = Node::new("Process".to_string(), NodeKind::Start);
661 let process_id = process_node.id;
662 workflow_v2.add_node(process_node);
663
664 workflow_v2.add_edge(Edge::new(start_id, process_id));
665 workflow_v2.add_edge(Edge::new(process_id, end_id));
666
667 let diff = WorkflowDiff::generate(&workflow_v1, &workflow_v2);
669
670 assert_eq!(diff.nodes_added.len(), 1);
671 assert_eq!(diff.nodes_added[0], "Process");
672 assert_eq!(diff.edges_added.len(), 2);
673 assert!(diff.has_changes());
674 }
675
676 #[test]
677 fn test_workflow_diff_no_changes() {
678 let mut workflow = Workflow::new("Test Workflow".to_string());
679 workflow.metadata.version = "1.0.0".to_string();
680
681 let start_node = Node::new("Start".to_string(), NodeKind::Start);
682 workflow.add_node(start_node);
683
684 let diff = WorkflowDiff::generate(&workflow, &workflow);
685 assert!(!diff.has_changes());
686 }
687
688 #[test]
689 fn test_breaking_changes_detection() {
690 let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
691
692 let entry1 = WorkflowVersionEntry {
693 version: "1.0.0".to_string(),
694 workflow_id: uuid::Uuid::new_v4(),
695 parent_id: None,
696 author: "Alice".to_string(),
697 created_at: Utc::now(),
698 change_description: "Initial".to_string(),
699 change_type: ChangeType::Major,
700 tags: vec![],
701 published: true,
702 changelog: vec![],
703 };
704 history.add_version(entry1);
705
706 let entry2 = WorkflowVersionEntry {
707 version: "2.0.0".to_string(),
708 workflow_id: uuid::Uuid::new_v4(),
709 parent_id: None,
710 author: "Alice".to_string(),
711 created_at: Utc::now(),
712 change_description: "Breaking change".to_string(),
713 change_type: ChangeType::Major,
714 tags: vec![],
715 published: true,
716 changelog: vec![ChangelogEntry {
717 entry_type: ChangelogType::Removed,
718 description: "Removed old API".to_string(),
719 affected_nodes: vec!["node1".to_string()],
720 breaking: true,
721 }],
722 };
723 history.add_version(entry2);
724
725 let breaking = history.breaking_changes_since("1.0.0");
726 assert_eq!(breaking.len(), 1);
727 assert_eq!(breaking[0].description, "Removed old API");
728 }
729
730 #[test]
731 fn test_get_history_between_versions() {
732 let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
733
734 for i in 0..5 {
735 let entry = WorkflowVersionEntry {
736 version: format!("1.{}.0", i),
737 workflow_id: uuid::Uuid::new_v4(),
738 parent_id: None,
739 author: "Alice".to_string(),
740 created_at: Utc::now(),
741 change_description: format!("Version {}", i),
742 change_type: ChangeType::Minor,
743 tags: vec![],
744 published: true,
745 changelog: vec![],
746 };
747 history.add_version(entry);
748 }
749
750 let between = history.get_history_between("1.0.0", "1.3.0");
751 assert_eq!(between.len(), 3);
752 assert_eq!(between[0].version, "1.1.0");
753 assert_eq!(between[1].version, "1.2.0");
754 assert_eq!(between[2].version, "1.3.0");
755 }
756
757 #[test]
758 fn test_diff_summary() {
759 let mut workflow_v1 = Workflow::new("Test".to_string());
760 workflow_v1.metadata.version = "1.0.0".to_string();
761
762 let mut workflow_v2 = workflow_v1.clone();
763 workflow_v2.metadata.version = "2.0.0".to_string();
764
765 let diff = WorkflowDiff::generate(&workflow_v1, &workflow_v2);
766 let summary = diff.summary();
767
768 assert!(summary.contains("1.0.0"));
769 assert!(summary.contains("2.0.0"));
770 }
771}