#![allow(dead_code)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ClipEditOperation {
NameChanged {
before: String,
after: String,
},
DescriptionChanged {
before: Option<String>,
after: Option<String>,
},
RatingChanged {
before: u8,
after: u8,
},
KeywordAdded {
keyword: String,
},
KeywordRemoved {
keyword: String,
},
InPointChanged {
before: Option<i64>,
after: Option<i64>,
},
OutPointChanged {
before: Option<i64>,
after: Option<i64>,
},
FavoriteToggled {
before: bool,
after: bool,
},
RejectedToggled {
before: bool,
after: bool,
},
CustomMetadataChanged {
key: String,
before: Option<String>,
after: Option<String>,
},
}
impl ClipEditOperation {
#[must_use]
pub fn invert(&self) -> Self {
match self {
Self::NameChanged { before, after } => Self::NameChanged {
before: after.clone(),
after: before.clone(),
},
Self::DescriptionChanged { before, after } => Self::DescriptionChanged {
before: after.clone(),
after: before.clone(),
},
Self::RatingChanged { before, after } => Self::RatingChanged {
before: *after,
after: *before,
},
Self::KeywordAdded { keyword } => Self::KeywordRemoved {
keyword: keyword.clone(),
},
Self::KeywordRemoved { keyword } => Self::KeywordAdded {
keyword: keyword.clone(),
},
Self::InPointChanged { before, after } => Self::InPointChanged {
before: *after,
after: *before,
},
Self::OutPointChanged { before, after } => Self::OutPointChanged {
before: *after,
after: *before,
},
Self::FavoriteToggled { before, after } => Self::FavoriteToggled {
before: *after,
after: *before,
},
Self::RejectedToggled { before, after } => Self::RejectedToggled {
before: *after,
after: *before,
},
Self::CustomMetadataChanged { key, before, after } => Self::CustomMetadataChanged {
key: key.clone(),
before: after.clone(),
after: before.clone(),
},
}
}
#[must_use]
pub fn description(&self) -> String {
match self {
Self::NameChanged { before, after } => {
format!("Rename \"{before}\" → \"{after}\"")
}
Self::DescriptionChanged { .. } => "Change description".to_string(),
Self::RatingChanged { before, after } => {
format!("Change rating {before}★ → {after}★")
}
Self::KeywordAdded { keyword } => format!("Add keyword \"{keyword}\""),
Self::KeywordRemoved { keyword } => format!("Remove keyword \"{keyword}\""),
Self::InPointChanged { after, .. } => match after {
Some(f) => format!("Set in-point to frame {f}"),
None => "Clear in-point".to_string(),
},
Self::OutPointChanged { after, .. } => match after {
Some(f) => format!("Set out-point to frame {f}"),
None => "Clear out-point".to_string(),
},
Self::FavoriteToggled { after, .. } => {
if *after {
"Mark as favourite".to_string()
} else {
"Unmark favourite".to_string()
}
}
Self::RejectedToggled { after, .. } => {
if *after {
"Mark as rejected".to_string()
} else {
"Unmark rejected".to_string()
}
}
Self::CustomMetadataChanged { key, .. } => {
format!("Change metadata field \"{key}\"")
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionEntry {
pub sequence: u64,
pub operation: ClipEditOperation,
pub timestamp: DateTime<Utc>,
pub label: Option<String>,
}
impl VersionEntry {
fn new(sequence: u64, operation: ClipEditOperation, label: Option<String>) -> Self {
Self {
sequence,
operation,
timestamp: Utc::now(),
label,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClipVersionHistory {
pub clip_id: String,
undo_stack: Vec<VersionEntry>,
redo_stack: Vec<VersionEntry>,
next_seq: u64,
max_depth: Option<usize>,
}
impl ClipVersionHistory {
#[must_use]
pub fn new(clip_id: String) -> Self {
Self {
clip_id,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
next_seq: 1,
max_depth: None,
}
}
#[must_use]
pub fn with_max_depth(clip_id: String, max_depth: usize) -> Self {
Self {
clip_id,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
next_seq: 1,
max_depth: Some(max_depth),
}
}
pub fn push(&mut self, operation: ClipEditOperation) {
self.push_labeled(operation, None);
}
pub fn push_labeled(&mut self, operation: ClipEditOperation, label: Option<String>) {
let seq = self.next_seq;
self.next_seq += 1;
self.redo_stack.clear();
self.undo_stack
.push(VersionEntry::new(seq, operation, label));
if let Some(max) = self.max_depth {
if self.undo_stack.len() > max {
let excess = self.undo_stack.len() - max;
self.undo_stack.drain(..excess);
}
}
}
pub fn undo(&mut self) -> Option<ClipEditOperation> {
let entry = self.undo_stack.pop()?;
let inverse = entry.operation.invert();
let seq = self.next_seq;
self.next_seq += 1;
self.redo_stack
.push(VersionEntry::new(seq, entry.operation, entry.label));
Some(inverse)
}
pub fn redo(&mut self) -> Option<ClipEditOperation> {
let entry = self.redo_stack.pop()?;
let op = entry.operation.clone();
let seq = self.next_seq;
self.next_seq += 1;
self.undo_stack
.push(VersionEntry::new(seq, entry.operation, entry.label));
Some(op)
}
#[must_use]
pub fn undo_count(&self) -> usize {
self.undo_stack.len()
}
#[must_use]
pub fn redo_count(&self) -> usize {
self.redo_stack.len()
}
#[must_use]
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
#[must_use]
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn clear(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
}
#[must_use]
pub fn history(&self) -> &[VersionEntry] {
&self.undo_stack
}
#[must_use]
pub fn latest(&self) -> Option<&VersionEntry> {
self.undo_stack.last()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VersionStore {
histories: HashMap<String, ClipVersionHistory>,
default_max_depth: Option<usize>,
}
impl VersionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_max_depth(max_depth: usize) -> Self {
Self {
histories: HashMap::new(),
default_max_depth: Some(max_depth),
}
}
pub fn history_mut(&mut self, clip_id: &str) -> &mut ClipVersionHistory {
self.histories
.entry(clip_id.to_string())
.or_insert_with(|| match self.default_max_depth {
Some(d) => ClipVersionHistory::with_max_depth(clip_id.to_string(), d),
None => ClipVersionHistory::new(clip_id.to_string()),
})
}
#[must_use]
pub fn history(&self, clip_id: &str) -> Option<&ClipVersionHistory> {
self.histories.get(clip_id)
}
pub fn push(&mut self, clip_id: &str, operation: ClipEditOperation) {
self.history_mut(clip_id).push(operation);
}
pub fn undo(&mut self, clip_id: &str) -> Option<ClipEditOperation> {
self.history_mut(clip_id).undo()
}
pub fn redo(&mut self, clip_id: &str) -> Option<ClipEditOperation> {
self.history_mut(clip_id).redo()
}
pub fn remove(&mut self, clip_id: &str) {
self.histories.remove(clip_id);
}
#[must_use]
pub fn clip_count(&self) -> usize {
self.histories.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_push_and_undo() {
let mut h = ClipVersionHistory::new("clip-01".to_string());
h.push(ClipEditOperation::NameChanged {
before: "old".to_string(),
after: "new".to_string(),
});
assert_eq!(h.undo_count(), 1);
let inv = h.undo().expect("should undo");
assert!(
matches!(inv, ClipEditOperation::NameChanged { before, after } if before == "new" && after == "old")
);
assert_eq!(h.undo_count(), 0);
assert_eq!(h.redo_count(), 1);
}
#[test]
fn test_redo_after_undo() {
let mut h = ClipVersionHistory::new("c1".to_string());
h.push(ClipEditOperation::FavoriteToggled {
before: false,
after: true,
});
h.undo();
let op = h.redo().expect("should redo");
assert!(matches!(
op,
ClipEditOperation::FavoriteToggled { after: true, .. }
));
assert_eq!(h.undo_count(), 1);
assert_eq!(h.redo_count(), 0);
}
#[test]
fn test_push_clears_redo() {
let mut h = ClipVersionHistory::new("c1".to_string());
h.push(ClipEditOperation::KeywordAdded {
keyword: "outdoor".to_string(),
});
h.undo();
assert_eq!(h.redo_count(), 1);
h.push(ClipEditOperation::KeywordAdded {
keyword: "indoor".to_string(),
});
assert_eq!(h.redo_count(), 0);
}
#[test]
fn test_max_depth_trims_oldest() {
let mut h = ClipVersionHistory::with_max_depth("c1".to_string(), 2);
h.push(ClipEditOperation::RatingChanged {
before: 0,
after: 3,
});
h.push(ClipEditOperation::RatingChanged {
before: 3,
after: 4,
});
h.push(ClipEditOperation::RatingChanged {
before: 4,
after: 5,
});
assert_eq!(h.undo_count(), 2);
}
#[test]
fn test_invert_keyword_added() {
let op = ClipEditOperation::KeywordAdded {
keyword: "kw".to_string(),
};
let inv = op.invert();
assert!(matches!(inv, ClipEditOperation::KeywordRemoved { keyword } if keyword == "kw"));
}
#[test]
fn test_version_store_multi_clip() {
let mut store = VersionStore::new();
store.push(
"clip-a",
ClipEditOperation::NameChanged {
before: "a".to_string(),
after: "A".to_string(),
},
);
store.push(
"clip-b",
ClipEditOperation::NameChanged {
before: "b".to_string(),
after: "B".to_string(),
},
);
assert_eq!(store.clip_count(), 2);
let inv = store.undo("clip-a").expect("should undo clip-a");
assert!(matches!(inv, ClipEditOperation::NameChanged { after, .. } if after == "a"));
}
#[test]
fn test_empty_undo_returns_none() {
let mut h = ClipVersionHistory::new("c1".to_string());
assert!(h.undo().is_none());
assert!(h.redo().is_none());
}
#[test]
fn test_operation_description() {
let op = ClipEditOperation::NameChanged {
before: "x".to_string(),
after: "y".to_string(),
};
let desc = op.description();
assert!(desc.contains("x"));
assert!(desc.contains("y"));
}
}