use crate::{Workflow, WorkflowId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct WorkflowVersionEntry {
pub version: String,
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub workflow_id: WorkflowId,
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub parent_id: Option<WorkflowId>,
pub author: String,
pub created_at: DateTime<Utc>,
pub change_description: String,
pub change_type: ChangeType,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub published: bool,
#[serde(default)]
pub changelog: Vec<ChangelogEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum ChangeType {
Major,
Minor,
Patch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ChangelogEntry {
pub entry_type: ChangelogType,
pub description: String,
#[serde(default)]
pub affected_nodes: Vec<String>,
#[serde(default)]
pub breaking: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum ChangelogType {
Added,
Changed,
Deprecated,
Removed,
Fixed,
Security,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct WorkflowVersionHistory {
pub workflow_name: String,
pub versions: Vec<WorkflowVersionEntry>,
#[serde(default)]
pub aliases: HashMap<String, String>,
}
impl WorkflowVersionHistory {
pub fn new(workflow_name: String) -> Self {
Self {
workflow_name,
versions: Vec::new(),
aliases: HashMap::new(),
}
}
pub fn add_version(&mut self, entry: WorkflowVersionEntry) {
self.versions.push(entry);
self.sort_versions();
}
fn sort_versions(&mut self) {
self.versions.sort_by(|a, b| {
let a_parts = parse_version(&a.version).unwrap_or((0, 0, 0));
let b_parts = parse_version(&b.version).unwrap_or((0, 0, 0));
a_parts.cmp(&b_parts)
});
}
pub fn latest_version(&self) -> Option<&WorkflowVersionEntry> {
self.versions.last()
}
pub fn get_version(&self, version: &str) -> Option<&WorkflowVersionEntry> {
let resolved_version = self
.aliases
.get(version)
.map(|s| s.as_str())
.unwrap_or(version);
self.versions.iter().find(|v| v.version == resolved_version)
}
pub fn published_versions(&self) -> Vec<&WorkflowVersionEntry> {
self.versions.iter().filter(|v| v.published).collect()
}
pub fn get_history_between(&self, from: &str, to: &str) -> Vec<&WorkflowVersionEntry> {
let from_idx = self.versions.iter().position(|v| v.version == from);
let to_idx = self.versions.iter().position(|v| v.version == to);
match (from_idx, to_idx) {
(Some(from), Some(to)) if from < to => self.versions[from + 1..=to].iter().collect(),
_ => Vec::new(),
}
}
pub fn set_alias(&mut self, alias: String, version: String) {
self.aliases.insert(alias, version);
}
pub fn breaking_changes_since(&self, version: &str) -> Vec<&ChangelogEntry> {
let from_idx = self.versions.iter().position(|v| v.version == version);
match from_idx {
Some(idx) => self.versions[idx + 1..]
.iter()
.flat_map(|v| &v.changelog)
.filter(|e| e.breaking)
.collect(),
None => Vec::new(),
}
}
pub fn requires_migration(&self, from: &str, to: &str) -> bool {
let from_parts = parse_version(from).unwrap_or((0, 0, 0));
let to_parts = parse_version(to).unwrap_or((0, 0, 0));
from_parts.0 != to_parts.0
}
}
#[derive(Debug)]
pub struct VersionCompatibility {
pub from_version: String,
pub to_version: String,
pub compatible: bool,
pub requires_migration: bool,
pub issues: Vec<String>,
pub breaking_changes: Vec<String>,
}
impl VersionCompatibility {
pub fn check(from: &str, to: &str, history: &WorkflowVersionHistory) -> Self {
let from_parts = parse_version(from).unwrap_or((0, 0, 0));
let to_parts = parse_version(to).unwrap_or((0, 0, 0));
let mut issues = Vec::new();
let mut breaking_changes = Vec::new();
let major_diff = to_parts.0 as i32 - from_parts.0 as i32;
let requires_migration = major_diff != 0;
let compatible = if major_diff < 0 {
issues.push(format!(
"Downgrading major version from {} to {} is not supported",
from, to
));
false
} else if major_diff > 1 {
issues.push(format!(
"Skipping major versions (from {} to {}) may have issues",
from, to
));
true
} else {
true
};
for entry in history.breaking_changes_since(from) {
breaking_changes.push(entry.description.clone());
}
Self {
from_version: from.to_string(),
to_version: to.to_string(),
compatible,
requires_migration,
issues,
breaking_changes,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct WorkflowDiff {
pub from_version: String,
pub to_version: String,
#[serde(default)]
pub nodes_added: Vec<String>,
#[serde(default)]
pub nodes_removed: Vec<String>,
#[serde(default)]
pub nodes_modified: Vec<NodeChange>,
#[serde(default)]
pub edges_added: Vec<EdgeInfo>,
#[serde(default)]
pub edges_removed: Vec<EdgeInfo>,
#[serde(default)]
pub metadata_changes: Vec<MetadataChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct NodeChange {
pub node_id: String,
pub node_name: String,
pub changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct EdgeInfo {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct MetadataChange {
pub field: String,
pub old_value: Option<String>,
pub new_value: Option<String>,
}
impl WorkflowDiff {
pub fn generate(from: &Workflow, to: &Workflow) -> Self {
let mut diff = Self {
from_version: from.metadata.version.clone(),
to_version: to.metadata.version.clone(),
nodes_added: Vec::new(),
nodes_removed: Vec::new(),
nodes_modified: Vec::new(),
edges_added: Vec::new(),
edges_removed: Vec::new(),
metadata_changes: Vec::new(),
};
let from_node_ids: HashMap<_, _> = from.nodes.iter().map(|n| (n.id, n)).collect();
let to_node_ids: HashMap<_, _> = to.nodes.iter().map(|n| (n.id, n)).collect();
for (id, node) in &to_node_ids {
if !from_node_ids.contains_key(id) {
diff.nodes_added.push(node.name.clone());
}
}
for (id, node) in &from_node_ids {
if !to_node_ids.contains_key(id) {
diff.nodes_removed.push(node.name.clone());
}
}
for (id, from_node) in &from_node_ids {
if let Some(to_node) = to_node_ids.get(id) {
let mut changes = Vec::new();
if from_node.name != to_node.name {
changes.push(format!("name: '{}' -> '{}'", from_node.name, to_node.name));
}
if format!("{:?}", from_node.kind) != format!("{:?}", to_node.kind) {
changes.push(format!(
"kind: '{:?}' -> '{:?}'",
from_node.kind, to_node.kind
));
}
if !changes.is_empty() {
diff.nodes_modified.push(NodeChange {
node_id: id.to_string(),
node_name: to_node.name.clone(),
changes,
});
}
}
}
let from_edges: Vec<_> = from
.edges
.iter()
.map(|e| (e.from.to_string(), e.to.to_string()))
.collect();
let to_edges: Vec<_> = to
.edges
.iter()
.map(|e| (e.from.to_string(), e.to.to_string()))
.collect();
for (from_id, to_id) in &to_edges {
if !from_edges.contains(&(from_id.clone(), to_id.clone())) {
diff.edges_added.push(EdgeInfo {
from: from_id.clone(),
to: to_id.clone(),
});
}
}
for (from_id, to_id) in &from_edges {
if !to_edges.contains(&(from_id.clone(), to_id.clone())) {
diff.edges_removed.push(EdgeInfo {
from: from_id.clone(),
to: to_id.clone(),
});
}
}
if from.metadata.name != to.metadata.name {
diff.metadata_changes.push(MetadataChange {
field: "name".to_string(),
old_value: Some(from.metadata.name.clone()),
new_value: Some(to.metadata.name.clone()),
});
}
if from.metadata.description != to.metadata.description {
diff.metadata_changes.push(MetadataChange {
field: "description".to_string(),
old_value: from.metadata.description.clone(),
new_value: to.metadata.description.clone(),
});
}
diff
}
pub fn has_changes(&self) -> bool {
!self.nodes_added.is_empty()
|| !self.nodes_removed.is_empty()
|| !self.nodes_modified.is_empty()
|| !self.edges_added.is_empty()
|| !self.edges_removed.is_empty()
|| !self.metadata_changes.is_empty()
}
pub fn summary(&self) -> String {
let mut lines = Vec::new();
lines.push(format!(
"Diff from version {} to {}",
self.from_version, self.to_version
));
if !self.nodes_added.is_empty() {
lines.push(format!(
"Added {} nodes: {:?}",
self.nodes_added.len(),
self.nodes_added
));
}
if !self.nodes_removed.is_empty() {
lines.push(format!(
"Removed {} nodes: {:?}",
self.nodes_removed.len(),
self.nodes_removed
));
}
if !self.nodes_modified.is_empty() {
lines.push(format!("Modified {} nodes", self.nodes_modified.len()));
}
if !self.edges_added.is_empty() {
lines.push(format!("Added {} edges", self.edges_added.len()));
}
if !self.edges_removed.is_empty() {
lines.push(format!("Removed {} edges", self.edges_removed.len()));
}
if !self.metadata_changes.is_empty() {
lines.push(format!(
"Changed {} metadata fields",
self.metadata_changes.len()
));
}
if !self.has_changes() {
lines.push("No changes detected".to_string());
}
lines.join("\n")
}
}
fn parse_version(version: &str) -> Result<(u32, u32, u32), String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return Err(format!("Invalid version format: {}", version));
}
let major = parts[0]
.parse::<u32>()
.map_err(|_| format!("Invalid major version: {}", parts[0]))?;
let minor = parts[1]
.parse::<u32>()
.map_err(|_| format!("Invalid minor version: {}", parts[1]))?;
let patch = parts[2]
.parse::<u32>()
.map_err(|_| format!("Invalid patch version: {}", parts[2]))?;
Ok((major, minor, patch))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edge, Node, NodeKind};
#[test]
fn test_version_history_creation() {
let history = WorkflowVersionHistory::new("My Workflow".to_string());
assert_eq!(history.workflow_name, "My Workflow");
assert!(history.versions.is_empty());
assert!(history.aliases.is_empty());
}
#[test]
fn test_add_version_to_history() {
let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
let entry = WorkflowVersionEntry {
version: "1.0.0".to_string(),
workflow_id: uuid::Uuid::new_v4(),
parent_id: None,
author: "Alice".to_string(),
created_at: Utc::now(),
change_description: "Initial version".to_string(),
change_type: ChangeType::Major,
tags: vec!["stable".to_string()],
published: true,
changelog: vec![],
};
history.add_version(entry);
assert_eq!(history.versions.len(), 1);
assert_eq!(history.latest_version().unwrap().version, "1.0.0");
}
#[test]
fn test_version_sorting() {
let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
for version in ["1.2.0", "1.0.0", "2.0.0", "1.1.0"] {
let entry = WorkflowVersionEntry {
version: version.to_string(),
workflow_id: uuid::Uuid::new_v4(),
parent_id: None,
author: "Alice".to_string(),
created_at: Utc::now(),
change_description: "Test".to_string(),
change_type: ChangeType::Minor,
tags: vec![],
published: true,
changelog: vec![],
};
history.add_version(entry);
}
assert_eq!(history.versions[0].version, "1.0.0");
assert_eq!(history.versions[1].version, "1.1.0");
assert_eq!(history.versions[2].version, "1.2.0");
assert_eq!(history.versions[3].version, "2.0.0");
}
#[test]
fn test_version_aliases() {
let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
let entry = WorkflowVersionEntry {
version: "1.0.0".to_string(),
workflow_id: uuid::Uuid::new_v4(),
parent_id: None,
author: "Alice".to_string(),
created_at: Utc::now(),
change_description: "Initial".to_string(),
change_type: ChangeType::Major,
tags: vec![],
published: true,
changelog: vec![],
};
history.add_version(entry);
history.set_alias("stable".to_string(), "1.0.0".to_string());
let version = history.get_version("stable");
assert!(version.is_some());
assert_eq!(version.unwrap().version, "1.0.0");
}
#[test]
fn test_version_compatibility_check() {
let history = WorkflowVersionHistory::new("My Workflow".to_string());
let compat = VersionCompatibility::check("1.0.0", "1.1.0", &history);
assert!(compat.compatible);
assert!(!compat.requires_migration);
let compat = VersionCompatibility::check("1.0.0", "2.0.0", &history);
assert!(compat.compatible);
assert!(compat.requires_migration);
let compat = VersionCompatibility::check("2.0.0", "1.0.0", &history);
assert!(!compat.compatible);
}
#[test]
fn test_workflow_diff_generation() {
let mut workflow_v1 = Workflow::new("Test Workflow".to_string());
workflow_v1.metadata.version = "1.0.0".to_string();
let start_node = Node::new("Start".to_string(), NodeKind::Start);
let start_id = start_node.id;
workflow_v1.add_node(start_node);
let end_node = Node::new("End".to_string(), NodeKind::End);
let end_id = end_node.id;
workflow_v1.add_node(end_node);
workflow_v1.add_edge(Edge::new(start_id, end_id));
let mut workflow_v2 = workflow_v1.clone();
workflow_v2.metadata.version = "1.1.0".to_string();
let process_node = Node::new("Process".to_string(), NodeKind::Start);
let process_id = process_node.id;
workflow_v2.add_node(process_node);
workflow_v2.add_edge(Edge::new(start_id, process_id));
workflow_v2.add_edge(Edge::new(process_id, end_id));
let diff = WorkflowDiff::generate(&workflow_v1, &workflow_v2);
assert_eq!(diff.nodes_added.len(), 1);
assert_eq!(diff.nodes_added[0], "Process");
assert_eq!(diff.edges_added.len(), 2);
assert!(diff.has_changes());
}
#[test]
fn test_workflow_diff_no_changes() {
let mut workflow = Workflow::new("Test Workflow".to_string());
workflow.metadata.version = "1.0.0".to_string();
let start_node = Node::new("Start".to_string(), NodeKind::Start);
workflow.add_node(start_node);
let diff = WorkflowDiff::generate(&workflow, &workflow);
assert!(!diff.has_changes());
}
#[test]
fn test_breaking_changes_detection() {
let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
let entry1 = WorkflowVersionEntry {
version: "1.0.0".to_string(),
workflow_id: uuid::Uuid::new_v4(),
parent_id: None,
author: "Alice".to_string(),
created_at: Utc::now(),
change_description: "Initial".to_string(),
change_type: ChangeType::Major,
tags: vec![],
published: true,
changelog: vec![],
};
history.add_version(entry1);
let entry2 = WorkflowVersionEntry {
version: "2.0.0".to_string(),
workflow_id: uuid::Uuid::new_v4(),
parent_id: None,
author: "Alice".to_string(),
created_at: Utc::now(),
change_description: "Breaking change".to_string(),
change_type: ChangeType::Major,
tags: vec![],
published: true,
changelog: vec![ChangelogEntry {
entry_type: ChangelogType::Removed,
description: "Removed old API".to_string(),
affected_nodes: vec!["node1".to_string()],
breaking: true,
}],
};
history.add_version(entry2);
let breaking = history.breaking_changes_since("1.0.0");
assert_eq!(breaking.len(), 1);
assert_eq!(breaking[0].description, "Removed old API");
}
#[test]
fn test_get_history_between_versions() {
let mut history = WorkflowVersionHistory::new("My Workflow".to_string());
for i in 0..5 {
let entry = WorkflowVersionEntry {
version: format!("1.{}.0", i),
workflow_id: uuid::Uuid::new_v4(),
parent_id: None,
author: "Alice".to_string(),
created_at: Utc::now(),
change_description: format!("Version {}", i),
change_type: ChangeType::Minor,
tags: vec![],
published: true,
changelog: vec![],
};
history.add_version(entry);
}
let between = history.get_history_between("1.0.0", "1.3.0");
assert_eq!(between.len(), 3);
assert_eq!(between[0].version, "1.1.0");
assert_eq!(between[1].version, "1.2.0");
assert_eq!(between[2].version, "1.3.0");
}
#[test]
fn test_diff_summary() {
let mut workflow_v1 = Workflow::new("Test".to_string());
workflow_v1.metadata.version = "1.0.0".to_string();
let mut workflow_v2 = workflow_v1.clone();
workflow_v2.metadata.version = "2.0.0".to_string();
let diff = WorkflowDiff::generate(&workflow_v1, &workflow_v2);
let summary = diff.summary();
assert!(summary.contains("1.0.0"));
assert!(summary.contains("2.0.0"));
}
}