use chrono::{DateTime, Utc};
use uuid::Uuid;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct RollbackRecord {
pub id: Uuid,
pub from_model_id: Uuid,
pub to_model_id: Uuid,
pub reason: String,
pub initiated_by: Option<String>,
pub timestamp: DateTime<Utc>,
}
impl RollbackRecord {
pub fn new(
from_model_id: Uuid,
to_model_id: Uuid,
reason: impl Into<String>,
initiated_by: Option<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
from_model_id,
to_model_id,
reason: reason.into(),
initiated_by,
timestamp: Utc::now(),
}
}
}
pub struct RollbackController {
history: Vec<RollbackRecord>,
max_history: usize,
}
impl Default for RollbackController {
fn default() -> Self {
Self::new(100)
}
}
impl RollbackController {
pub fn new(max_history: usize) -> Self {
Self {
history: Vec::new(),
max_history,
}
}
pub fn record_rollback(
&mut self,
from_id: Uuid,
to_id: Uuid,
reason: impl Into<String>,
initiated_by: Option<String>,
) -> RollbackRecord {
let record = RollbackRecord::new(from_id, to_id, reason, initiated_by);
self.history.push(record.clone());
if self.history.len() > self.max_history {
self.history.remove(0);
}
record
}
pub fn history(&self) -> &[RollbackRecord] {
&self.history
}
pub fn last_rollback(&self) -> Option<&RollbackRecord> {
self.history.last()
}
pub fn rollbacks_involving(&self, model_id: Uuid) -> Vec<&RollbackRecord> {
self.history
.iter()
.filter(|r| r.from_model_id == model_id || r.to_model_id == model_id)
.collect()
}
pub fn rollbacks_from(&self, model_id: Uuid) -> Vec<&RollbackRecord> {
self.history
.iter()
.filter(|r| r.from_model_id == model_id)
.collect()
}
pub fn rollbacks_to(&self, model_id: Uuid) -> Vec<&RollbackRecord> {
self.history
.iter()
.filter(|r| r.to_model_id == model_id)
.collect()
}
pub fn len(&self) -> usize {
self.history.len()
}
pub fn is_empty(&self) -> bool {
self.history.is_empty()
}
pub fn clear_history(&mut self) {
self.history.clear();
}
pub fn max_history(&self) -> usize {
self.max_history
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rollback_record_creation() {
let from_id = Uuid::new_v4();
let to_id = Uuid::new_v4();
let record = RollbackRecord::new(from_id, to_id, "test reason", Some("tester".to_string()));
assert_eq!(record.from_model_id, from_id);
assert_eq!(record.to_model_id, to_id);
assert_eq!(record.reason, "test reason");
assert_eq!(record.initiated_by, Some("tester".to_string()));
}
#[test]
fn test_controller_default() {
let controller = RollbackController::default();
assert_eq!(controller.max_history(), 100);
assert!(controller.is_empty());
}
#[test]
fn test_record_rollback() {
let mut controller = RollbackController::new(10);
let from_id = Uuid::new_v4();
let to_id = Uuid::new_v4();
let record =
controller.record_rollback(from_id, to_id, "perf regression", Some("ops".to_string()));
assert_eq!(controller.len(), 1);
assert_eq!(record.from_model_id, from_id);
assert_eq!(record.to_model_id, to_id);
assert_eq!(record.reason, "perf regression");
}
#[test]
fn test_history_trimming() {
let mut controller = RollbackController::new(3);
for i in 0..5 {
let from_id = Uuid::new_v4();
let to_id = Uuid::new_v4();
controller.record_rollback(from_id, to_id, format!("reason {i}"), None);
}
assert_eq!(controller.len(), 3);
assert_eq!(controller.history()[0].reason, "reason 2");
assert_eq!(controller.history()[2].reason, "reason 4");
}
#[test]
fn test_last_rollback() {
let mut controller = RollbackController::new(10);
assert!(controller.last_rollback().is_none());
let from_id = Uuid::new_v4();
let to_id = Uuid::new_v4();
controller.record_rollback(from_id, to_id, "first", None);
let from_id2 = Uuid::new_v4();
let to_id2 = Uuid::new_v4();
controller.record_rollback(from_id2, to_id2, "second", None);
assert_eq!(controller.last_rollback().unwrap().reason, "second");
}
#[test]
fn test_rollbacks_involving() {
let mut controller = RollbackController::new(10);
let model_a = Uuid::new_v4();
let model_b = Uuid::new_v4();
let model_c = Uuid::new_v4();
controller.record_rollback(model_a, model_b, "rollback 1", None);
controller.record_rollback(model_b, model_c, "rollback 2", None);
controller.record_rollback(model_c, model_a, "rollback 3", None);
let involving_a = controller.rollbacks_involving(model_a);
assert_eq!(involving_a.len(), 2);
let involving_b = controller.rollbacks_involving(model_b);
assert_eq!(involving_b.len(), 2);
let involving_c = controller.rollbacks_involving(model_c);
assert_eq!(involving_c.len(), 2);
}
#[test]
fn test_rollbacks_from_and_to() {
let mut controller = RollbackController::new(10);
let model_a = Uuid::new_v4();
let model_b = Uuid::new_v4();
let model_c = Uuid::new_v4();
controller.record_rollback(model_a, model_b, "1", None);
controller.record_rollback(model_a, model_c, "2", None);
controller.record_rollback(model_b, model_c, "3", None);
assert_eq!(controller.rollbacks_from(model_a).len(), 2);
assert_eq!(controller.rollbacks_to(model_a).len(), 0);
assert_eq!(controller.rollbacks_to(model_c).len(), 2);
assert_eq!(controller.rollbacks_from(model_c).len(), 0);
}
#[test]
fn test_clear_history() {
let mut controller = RollbackController::new(10);
controller.record_rollback(Uuid::new_v4(), Uuid::new_v4(), "test", None);
controller.record_rollback(Uuid::new_v4(), Uuid::new_v4(), "test", None);
assert_eq!(controller.len(), 2);
controller.clear_history();
assert!(controller.is_empty());
assert!(controller.last_rollback().is_none());
}
}