oxify_model/
versioning.rs

1//! Workflow versioning and change tracking system
2//!
3//! This module provides comprehensive version management for workflows,
4//! including version history, compatibility checks, and diff generation.
5
6use 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/// Workflow version history entry
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(ToSchema))]
17pub struct WorkflowVersionEntry {
18    /// Version number (e.g., "1.2.3")
19    pub version: String,
20
21    /// Workflow ID at this version
22    #[cfg_attr(feature = "openapi", schema(value_type = String))]
23    pub workflow_id: WorkflowId,
24
25    /// Parent workflow ID (previous version)
26    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
27    pub parent_id: Option<WorkflowId>,
28
29    /// Author of this version
30    pub author: String,
31
32    /// Timestamp when this version was created
33    pub created_at: DateTime<Utc>,
34
35    /// Description of changes in this version
36    pub change_description: String,
37
38    /// Type of change (Major, Minor, Patch)
39    pub change_type: ChangeType,
40
41    /// Tags for this version
42    #[serde(default)]
43    pub tags: Vec<String>,
44
45    /// Whether this version is published/released
46    #[serde(default)]
47    pub published: bool,
48
49    /// Changelog entries
50    #[serde(default)]
51    pub changelog: Vec<ChangelogEntry>,
52}
53
54/// Type of version change
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(ToSchema))]
57pub enum ChangeType {
58    /// Breaking changes (1.0.0 -> 2.0.0)
59    Major,
60    /// New features, backward compatible (1.0.0 -> 1.1.0)
61    Minor,
62    /// Bug fixes (1.0.0 -> 1.0.1)
63    Patch,
64}
65
66/// Detailed changelog entry
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "openapi", derive(ToSchema))]
69pub struct ChangelogEntry {
70    /// Type of change
71    pub entry_type: ChangelogType,
72
73    /// Description of the change
74    pub description: String,
75
76    /// Node IDs affected by this change
77    #[serde(default)]
78    pub affected_nodes: Vec<String>,
79
80    /// Whether this is a breaking change
81    #[serde(default)]
82    pub breaking: bool,
83}
84
85/// Type of changelog entry
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "openapi", derive(ToSchema))]
88pub enum ChangelogType {
89    /// New feature added
90    Added,
91    /// Feature modified
92    Changed,
93    /// Feature deprecated
94    Deprecated,
95    /// Feature removed
96    Removed,
97    /// Bug fixed
98    Fixed,
99    /// Security fix
100    Security,
101}
102
103/// Complete version history for a workflow
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "openapi", derive(ToSchema))]
106pub struct WorkflowVersionHistory {
107    /// Root workflow name
108    pub workflow_name: String,
109
110    /// All versions, ordered from oldest to newest
111    pub versions: Vec<WorkflowVersionEntry>,
112
113    /// Version aliases (e.g., "latest" -> "1.2.3", "stable" -> "1.0.0")
114    #[serde(default)]
115    pub aliases: HashMap<String, String>,
116}
117
118impl WorkflowVersionHistory {
119    /// Create a new version history
120    pub fn new(workflow_name: String) -> Self {
121        Self {
122            workflow_name,
123            versions: Vec::new(),
124            aliases: HashMap::new(),
125        }
126    }
127
128    /// Add a new version to the history
129    pub fn add_version(&mut self, entry: WorkflowVersionEntry) {
130        self.versions.push(entry);
131        self.sort_versions();
132    }
133
134    /// Sort versions by semantic version
135    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    /// Get the latest version
144    pub fn latest_version(&self) -> Option<&WorkflowVersionEntry> {
145        self.versions.last()
146    }
147
148    /// Get a specific version
149    pub fn get_version(&self, version: &str) -> Option<&WorkflowVersionEntry> {
150        // Check if it's an alias
151        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    /// Get all published versions
161    pub fn published_versions(&self) -> Vec<&WorkflowVersionEntry> {
162        self.versions.iter().filter(|v| v.published).collect()
163    }
164
165    /// Get version history between two versions
166    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    /// Set a version alias
177    pub fn set_alias(&mut self, alias: String, version: String) {
178        self.aliases.insert(alias, version);
179    }
180
181    /// Get all breaking changes since a version
182    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    /// Check if upgrade from one version to another requires migration
196    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        // Major version change requires migration
201        from_parts.0 != to_parts.0
202    }
203}
204
205/// Workflow version compatibility checker
206#[derive(Debug)]
207pub struct VersionCompatibility {
208    /// Source version
209    pub from_version: String,
210
211    /// Target version
212    pub to_version: String,
213
214    /// Whether versions are compatible
215    pub compatible: bool,
216
217    /// Whether migration is required
218    pub requires_migration: bool,
219
220    /// Compatibility issues
221    pub issues: Vec<String>,
222
223    /// Breaking changes
224    pub breaking_changes: Vec<String>,
225}
226
227impl VersionCompatibility {
228    /// Check compatibility between two versions
229    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        // Check major version difference
237        let major_diff = to_parts.0 as i32 - from_parts.0 as i32;
238        let requires_migration = major_diff != 0;
239
240        // Downgrading major version is not compatible
241        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        // Collect breaking changes
258        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/// Workflow diff between two versions
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "openapi", derive(ToSchema))]
276pub struct WorkflowDiff {
277    /// Source version
278    pub from_version: String,
279
280    /// Target version
281    pub to_version: String,
282
283    /// Nodes added
284    #[serde(default)]
285    pub nodes_added: Vec<String>,
286
287    /// Nodes removed
288    #[serde(default)]
289    pub nodes_removed: Vec<String>,
290
291    /// Nodes modified
292    #[serde(default)]
293    pub nodes_modified: Vec<NodeChange>,
294
295    /// Edges added
296    #[serde(default)]
297    pub edges_added: Vec<EdgeInfo>,
298
299    /// Edges removed
300    #[serde(default)]
301    pub edges_removed: Vec<EdgeInfo>,
302
303    /// Metadata changes
304    #[serde(default)]
305    pub metadata_changes: Vec<MetadataChange>,
306}
307
308/// Node change detail
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[cfg_attr(feature = "openapi", derive(ToSchema))]
311pub struct NodeChange {
312    /// Node ID
313    pub node_id: String,
314
315    /// Node name
316    pub node_name: String,
317
318    /// Fields that changed
319    pub changes: Vec<String>,
320}
321
322/// Edge information for diff
323#[derive(Debug, Clone, Serialize, Deserialize)]
324#[cfg_attr(feature = "openapi", derive(ToSchema))]
325pub struct EdgeInfo {
326    /// Source node ID
327    pub from: String,
328
329    /// Target node ID
330    pub to: String,
331}
332
333/// Metadata change detail
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[cfg_attr(feature = "openapi", derive(ToSchema))]
336pub struct MetadataChange {
337    /// Field name
338    pub field: String,
339
340    /// Old value
341    pub old_value: Option<String>,
342
343    /// New value
344    pub new_value: Option<String>,
345}
346
347impl WorkflowDiff {
348    /// Generate a diff between two workflows
349    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        // Compare nodes
362        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        // Find added nodes
366        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        // Find removed nodes
373        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        // Find modified nodes
380        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        // Compare edges
406        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        // Compare metadata
436        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    /// Check if there are any changes
456    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    /// Generate a human-readable summary
466    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
517/// Parse semantic version string into (major, minor, patch)
518fn 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        // Add versions in random order
577        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        // Should be sorted
594        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        // Create v2 with an additional node
657        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        // Generate diff
668        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}