Skip to main content

oximedia_workflow/
versioning.rs

1//! Workflow version management and diffing.
2//!
3//! [`WorkflowVersion`] represents a named snapshot of a workflow's serialised
4//! data string (e.g. a JSON or YAML definition). Two versions can be compared
5//! with [`WorkflowVersion::diff`] which returns a line-level diff as a
6//! `Vec<String>` in unified diff style (lines prefixed with `+` or `-`).
7//!
8//! # Design
9//!
10//! - Versions are identified by a numeric `id` (e.g. a monotonically increasing
11//!   revision number or a timestamp).
12//! - The `data` field holds the full serialised workflow as a `String`.
13//! - [`WorkflowVersion::diff`] compares `self` against `other` line by line,
14//!   returning lines that appear only in `self` (prefixed `+`) or only in
15//!   `other` (prefixed `-`).
16//!
17//! # Example
18//!
19//! ```rust
20//! use oximedia_workflow::versioning::WorkflowVersion;
21//!
22//! let v1 = WorkflowVersion::new(1, "step: ingest\nstep: transcode\n");
23//! let v2 = WorkflowVersion::new(2, "step: ingest\nstep: upload\n");
24//!
25//! let diff = v1.diff(&v2);
26//! assert!(diff.iter().any(|l| l.starts_with('+')));
27//! assert!(diff.iter().any(|l| l.starts_with('-')));
28//! ```
29
30#![allow(dead_code)]
31
32use std::collections::HashSet;
33
34// ---------------------------------------------------------------------------
35// WorkflowVersion
36// ---------------------------------------------------------------------------
37
38/// A versioned snapshot of a workflow's serialised definition.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct WorkflowVersion {
41    /// Monotonically increasing version identifier.
42    pub id: u64,
43    /// Full serialised workflow data (JSON, YAML, or custom DSL string).
44    pub data: String,
45}
46
47impl WorkflowVersion {
48    /// Create a new workflow version.
49    #[must_use]
50    pub fn new(id: u64, data: impl Into<String>) -> Self {
51        Self {
52            id,
53            data: data.into(),
54        }
55    }
56
57    // ── diffing ───────────────────────────────────────────────────────────────
58
59    /// Compute a line-level diff between `self` and `other`.
60    ///
61    /// Returns a `Vec<String>` where:
62    /// - Lines prefixed with `"+"` are present in `self` but not in `other`
63    ///   (added relative to `other`).
64    /// - Lines prefixed with `"-"` are present in `other` but not in `self`
65    ///   (removed relative to `other`, i.e. present in the older version).
66    ///
67    /// Lines common to both versions are **not** included in the output.
68    /// The order within each group (additions, removals) follows their order
69    /// of first appearance in the respective version.
70    ///
71    /// For an identical pair the returned `Vec` is empty.
72    #[must_use]
73    pub fn diff(&self, other: &WorkflowVersion) -> Vec<String> {
74        let self_lines: Vec<&str> = self.data.lines().collect();
75        let other_lines: Vec<&str> = other.data.lines().collect();
76
77        let self_set: HashSet<&str> = self_lines.iter().copied().collect();
78        let other_set: HashSet<&str> = other_lines.iter().copied().collect();
79
80        let mut result: Vec<String> = Vec::new();
81
82        // Lines in self but not in other → added (+).
83        for &line in &self_lines {
84            if !other_set.contains(line) {
85                result.push(format!("+{line}"));
86            }
87        }
88
89        // Lines in other but not in self → removed (-).
90        for &line in &other_lines {
91            if !self_set.contains(line) {
92                result.push(format!("-{line}"));
93            }
94        }
95
96        result
97    }
98
99    /// Return `true` if `self` and `other` contain identical data strings.
100    #[must_use]
101    pub fn is_identical(&self, other: &WorkflowVersion) -> bool {
102        self.data == other.data
103    }
104
105    /// Return a compact summary of this version's statistics.
106    #[must_use]
107    pub fn summary(&self) -> VersionSummary {
108        let line_count = self.data.lines().count();
109        let byte_count = self.data.len();
110        VersionSummary {
111            id: self.id,
112            line_count,
113            byte_count,
114        }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// VersionSummary
120// ---------------------------------------------------------------------------
121
122/// Compact statistics for a [`WorkflowVersion`].
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct VersionSummary {
125    /// Version identifier.
126    pub id: u64,
127    /// Number of lines in the data string.
128    pub line_count: usize,
129    /// Size in bytes of the data string.
130    pub byte_count: usize,
131}
132
133// ---------------------------------------------------------------------------
134// WorkflowVersionRegistry
135// ---------------------------------------------------------------------------
136
137/// An ordered registry of workflow versions, supporting diff and history queries.
138#[derive(Debug, Default)]
139pub struct WorkflowVersionRegistry {
140    versions: Vec<WorkflowVersion>,
141}
142
143impl WorkflowVersionRegistry {
144    /// Create an empty registry.
145    #[must_use]
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Append a new version.
151    pub fn push(&mut self, version: WorkflowVersion) {
152        self.versions.push(version);
153    }
154
155    /// Return the most recent version, if any.
156    #[must_use]
157    pub fn latest(&self) -> Option<&WorkflowVersion> {
158        self.versions.last()
159    }
160
161    /// Find a version by its `id`.
162    #[must_use]
163    pub fn get(&self, id: u64) -> Option<&WorkflowVersion> {
164        self.versions.iter().find(|v| v.id == id)
165    }
166
167    /// Compute the diff between two versions identified by their `id`s.
168    ///
169    /// Returns `None` if either id is not found.
170    #[must_use]
171    pub fn diff_ids(&self, from_id: u64, to_id: u64) -> Option<Vec<String>> {
172        let from = self.get(from_id)?;
173        let to = self.get(to_id)?;
174        Some(from.diff(to))
175    }
176
177    /// Number of versions stored.
178    #[must_use]
179    pub fn len(&self) -> usize {
180        self.versions.len()
181    }
182
183    /// Return `true` if no versions are stored.
184    #[must_use]
185    pub fn is_empty(&self) -> bool {
186        self.versions.is_empty()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_new_stores_id_and_data() {
196        let v = WorkflowVersion::new(42, "hello world");
197        assert_eq!(v.id, 42);
198        assert_eq!(v.data, "hello world");
199    }
200
201    // ── diff ─────────────────────────────────────────────────────────────────
202
203    #[test]
204    fn test_diff_identical_is_empty() {
205        let v1 = WorkflowVersion::new(1, "a\nb\nc");
206        let v2 = WorkflowVersion::new(2, "a\nb\nc");
207        assert!(v1.diff(&v2).is_empty());
208    }
209
210    #[test]
211    fn test_diff_added_lines() {
212        let v1 = WorkflowVersion::new(1, "a\nb\nc");
213        let v2 = WorkflowVersion::new(2, "a\nb");
214        let diff = v1.diff(&v2);
215        // "c" is in v1 but not v2 → added.
216        assert!(
217            diff.iter().any(|l| l == "+c"),
218            "Expected +c in diff: {diff:?}"
219        );
220    }
221
222    #[test]
223    fn test_diff_removed_lines() {
224        let v1 = WorkflowVersion::new(1, "a\nb");
225        let v2 = WorkflowVersion::new(2, "a\nb\nc");
226        let diff = v1.diff(&v2);
227        // "c" is in v2 but not v1 → removed.
228        assert!(
229            diff.iter().any(|l| l == "-c"),
230            "Expected -c in diff: {diff:?}"
231        );
232    }
233
234    #[test]
235    fn test_diff_both_adds_and_removes() {
236        let v1 = WorkflowVersion::new(1, "step: ingest\nstep: transcode\n");
237        let v2 = WorkflowVersion::new(2, "step: ingest\nstep: upload\n");
238        let diff = v1.diff(&v2);
239        assert!(diff.iter().any(|l| l.starts_with('+')));
240        assert!(diff.iter().any(|l| l.starts_with('-')));
241    }
242
243    #[test]
244    fn test_is_identical_same_data() {
245        let v1 = WorkflowVersion::new(1, "data");
246        let v2 = WorkflowVersion::new(2, "data");
247        assert!(v1.is_identical(&v2));
248    }
249
250    #[test]
251    fn test_is_identical_different_data() {
252        let v1 = WorkflowVersion::new(1, "a");
253        let v2 = WorkflowVersion::new(2, "b");
254        assert!(!v1.is_identical(&v2));
255    }
256
257    // ── summary ───────────────────────────────────────────────────────────────
258
259    #[test]
260    fn test_summary_line_count() {
261        let v = WorkflowVersion::new(1, "a\nb\nc");
262        let s = v.summary();
263        assert_eq!(s.id, 1);
264        assert_eq!(s.line_count, 3);
265        assert_eq!(s.byte_count, "a\nb\nc".len());
266    }
267
268    // ── registry ─────────────────────────────────────────────────────────────
269
270    #[test]
271    fn test_registry_push_and_get() {
272        let mut reg = WorkflowVersionRegistry::new();
273        reg.push(WorkflowVersion::new(1, "v1"));
274        reg.push(WorkflowVersion::new(2, "v2"));
275        assert_eq!(reg.len(), 2);
276        assert_eq!(reg.get(1).map(|v| v.data.as_str()), Some("v1"));
277        assert_eq!(reg.get(2).map(|v| v.data.as_str()), Some("v2"));
278    }
279
280    #[test]
281    fn test_registry_latest() {
282        let mut reg = WorkflowVersionRegistry::new();
283        assert!(reg.latest().is_none());
284        reg.push(WorkflowVersion::new(1, "v1"));
285        reg.push(WorkflowVersion::new(2, "v2"));
286        assert_eq!(reg.latest().map(|v| v.id), Some(2));
287    }
288
289    #[test]
290    fn test_registry_diff_ids() {
291        let mut reg = WorkflowVersionRegistry::new();
292        reg.push(WorkflowVersion::new(1, "a\nb"));
293        reg.push(WorkflowVersion::new(2, "a\nc"));
294        let diff = reg.diff_ids(1, 2);
295        assert!(diff.is_some());
296        let diff = diff.unwrap();
297        assert!(!diff.is_empty());
298    }
299
300    #[test]
301    fn test_registry_diff_ids_missing_returns_none() {
302        let reg = WorkflowVersionRegistry::new();
303        assert!(reg.diff_ids(1, 2).is_none());
304    }
305
306    #[test]
307    fn test_registry_is_empty() {
308        let mut reg = WorkflowVersionRegistry::new();
309        assert!(reg.is_empty());
310        reg.push(WorkflowVersion::new(1, "x"));
311        assert!(!reg.is_empty());
312    }
313}