use crate::{ArchiveError, ArchiveResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PreconditionStatus {
Ok,
Failed,
Skipped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreconditionCheck {
pub name: String,
pub message: String,
pub status: PreconditionStatus,
}
impl PreconditionCheck {
#[must_use]
pub fn ok(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
message: message.into(),
status: PreconditionStatus::Ok,
}
}
#[must_use]
pub fn failed(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
message: message.into(),
status: PreconditionStatus::Failed,
}
}
#[must_use]
pub fn skipped(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
message: message.into(),
status: PreconditionStatus::Skipped,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedAction {
pub source_path: PathBuf,
pub target_path: PathBuf,
pub source_format: String,
pub target_format: String,
pub quality_loss: bool,
pub reversible: bool,
pub preconditions: Vec<PreconditionCheck>,
pub estimated_output_bytes: Option<u64>,
pub estimated_cpu_secs: Option<f64>,
pub notes: String,
}
impl PlannedAction {
#[must_use]
pub fn new(
source_path: impl Into<PathBuf>,
target_path: impl Into<PathBuf>,
source_format: impl Into<String>,
target_format: impl Into<String>,
) -> Self {
Self {
source_path: source_path.into(),
target_path: target_path.into(),
source_format: source_format.into(),
target_format: target_format.into(),
quality_loss: false,
reversible: true,
preconditions: Vec::new(),
estimated_output_bytes: None,
estimated_cpu_secs: None,
notes: String::new(),
}
}
pub fn add_precondition(&mut self, check: PreconditionCheck) {
self.preconditions.push(check);
}
#[must_use]
pub fn preconditions_satisfied(&self) -> bool {
self.preconditions
.iter()
.all(|c| c.status != PreconditionStatus::Failed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DryRunPlan {
pub generated_at: DateTime<Utc>,
pub campaign_name: String,
pub actions: Vec<PlannedAction>,
pub global_notes: Vec<String>,
}
impl DryRunPlan {
#[must_use]
pub fn new(campaign_name: impl Into<String>) -> Self {
Self {
generated_at: Utc::now(),
campaign_name: campaign_name.into(),
actions: Vec::new(),
global_notes: Vec::new(),
}
}
pub fn add_action(&mut self, action: PlannedAction) {
self.actions.push(action);
}
pub fn add_note(&mut self, note: impl Into<String>) {
self.global_notes.push(note.into());
}
#[must_use]
pub fn action_count(&self) -> usize {
self.actions.len()
}
#[must_use]
pub fn ready_count(&self) -> usize {
self.actions
.iter()
.filter(|a| a.preconditions_satisfied())
.count()
}
#[must_use]
pub fn blocked_count(&self) -> usize {
self.actions
.iter()
.filter(|a| !a.preconditions_satisfied())
.count()
}
#[must_use]
pub fn total_estimated_output_bytes(&self) -> u64 {
self.actions
.iter()
.filter(|a| a.preconditions_satisfied())
.filter_map(|a| a.estimated_output_bytes)
.fold(0u64, |acc, n| acc.saturating_add(n))
}
#[must_use]
pub fn total_estimated_cpu_secs(&self) -> f64 {
self.actions
.iter()
.filter(|a| a.preconditions_satisfied())
.filter_map(|a| a.estimated_cpu_secs)
.sum()
}
pub fn to_json(&self) -> ArchiveResult<String> {
serde_json::to_string_pretty(self).map_err(|e| ArchiveError::Validation(e.to_string()))
}
pub fn from_json(json: &str) -> ArchiveResult<Self> {
serde_json::from_str(json).map_err(|e| ArchiveError::Validation(e.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActionOutcome {
Success,
Failed,
Skipped,
RolledBack,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEntry {
pub entry_id: String,
pub source_path: PathBuf,
pub target_path: PathBuf,
pub backup_path: Option<PathBuf>,
pub source_checksum: Option<String>,
pub target_checksum: Option<String>,
pub executed_at: DateTime<Utc>,
pub outcome: ActionOutcome,
pub error_message: Option<String>,
}
impl JournalEntry {
#[must_use]
pub fn success(
entry_id: impl Into<String>,
source_path: impl Into<PathBuf>,
target_path: impl Into<PathBuf>,
) -> Self {
Self {
entry_id: entry_id.into(),
source_path: source_path.into(),
target_path: target_path.into(),
backup_path: None,
source_checksum: None,
target_checksum: None,
executed_at: Utc::now(),
outcome: ActionOutcome::Success,
error_message: None,
}
}
#[must_use]
pub fn failed(
entry_id: impl Into<String>,
source_path: impl Into<PathBuf>,
target_path: impl Into<PathBuf>,
error: impl Into<String>,
) -> Self {
Self {
entry_id: entry_id.into(),
source_path: source_path.into(),
target_path: target_path.into(),
backup_path: None,
source_checksum: None,
target_checksum: None,
executed_at: Utc::now(),
outcome: ActionOutcome::Failed,
error_message: Some(error.into()),
}
}
#[must_use]
pub fn can_rollback(&self) -> bool {
self.outcome == ActionOutcome::Success && self.backup_path.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackJournal {
pub campaign_name: String,
pub started_at: DateTime<Utc>,
pub entries: Vec<JournalEntry>,
pub tags: HashMap<String, String>,
}
impl RollbackJournal {
#[must_use]
pub fn new(campaign_name: impl Into<String>) -> Self {
Self {
campaign_name: campaign_name.into(),
started_at: Utc::now(),
entries: Vec::new(),
tags: HashMap::new(),
}
}
pub fn record(&mut self, entry: JournalEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn rollback_candidates(&self) -> Vec<&JournalEntry> {
self.entries.iter().filter(|e| e.can_rollback()).collect()
}
#[must_use]
pub fn build_rollback_plan(&self) -> Vec<RollbackStep> {
self.entries
.iter()
.filter(|e| e.can_rollback())
.map(|e| RollbackStep {
entry_id: e.entry_id.clone(),
migrated_path: e.target_path.clone(),
restore_from: e
.backup_path
.clone()
.expect("can_rollback guarantees backup_path is Some"),
source_checksum: e.source_checksum.clone(),
})
.collect()
}
pub fn mark_rolled_back(&mut self, entry_id: &str) -> ArchiveResult<()> {
let entry = self
.entries
.iter_mut()
.find(|e| e.entry_id == entry_id)
.ok_or_else(|| ArchiveError::Validation(format!("entry '{}' not found", entry_id)))?;
if !entry.can_rollback() {
return Err(ArchiveError::Validation(format!(
"entry '{}' cannot be rolled back (outcome={:?}, backup={:?})",
entry_id, entry.outcome, entry.backup_path
)));
}
entry.outcome = ActionOutcome::RolledBack;
Ok(())
}
#[must_use]
pub fn rolled_back_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.outcome == ActionOutcome::RolledBack)
.count()
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn success_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.outcome == ActionOutcome::Success)
.count()
}
pub fn to_json(&self) -> ArchiveResult<String> {
serde_json::to_string_pretty(self).map_err(|e| ArchiveError::Validation(e.to_string()))
}
pub fn from_json(json: &str) -> ArchiveResult<Self> {
serde_json::from_str(json).map_err(|e| ArchiveError::Validation(e.to_string()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackStep {
pub entry_id: String,
pub migrated_path: PathBuf,
pub restore_from: PathBuf,
pub source_checksum: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dry_run_plan_empty() {
let plan = DryRunPlan::new("2026-Q1 DV Migration");
assert_eq!(plan.action_count(), 0);
assert_eq!(plan.ready_count(), 0);
assert_eq!(plan.blocked_count(), 0);
}
#[test]
fn test_dry_run_plan_ready_action() {
let mut plan = DryRunPlan::new("test");
let mut action = PlannedAction::new("/src/a.dv", "/dst/a.dpx", "DV", "DPX");
action.add_precondition(PreconditionCheck::ok("disk_space", "50 GB free"));
action.estimated_output_bytes = Some(2_000_000_000);
action.estimated_cpu_secs = Some(120.0);
plan.add_action(action);
assert_eq!(plan.ready_count(), 1);
assert_eq!(plan.blocked_count(), 0);
assert_eq!(plan.total_estimated_output_bytes(), 2_000_000_000);
assert!((plan.total_estimated_cpu_secs() - 120.0).abs() < 1e-9);
}
#[test]
fn test_dry_run_plan_blocked_action() {
let mut plan = DryRunPlan::new("test");
let mut action = PlannedAction::new("/src/b.dv", "/dst/b.dpx", "DV", "DPX");
action.add_precondition(PreconditionCheck::failed("disk_space", "Only 1 GB free"));
plan.add_action(action);
assert_eq!(plan.ready_count(), 0);
assert_eq!(plan.blocked_count(), 1);
}
#[test]
fn test_dry_run_plan_mixed_preconditions() {
let mut plan = DryRunPlan::new("test");
let mut action = PlannedAction::new("/src/c.dv", "/dst/c.dpx", "DV", "DPX");
action.add_precondition(PreconditionCheck::ok("tool", "ffmpeg present"));
action.add_precondition(PreconditionCheck::failed("space", "insufficient space"));
plan.add_action(action);
assert_eq!(
plan.blocked_count(),
1,
"one failed check blocks the action"
);
}
#[test]
fn test_dry_run_plan_skipped_precondition_not_blocking() {
let mut plan = DryRunPlan::new("test");
let mut action = PlannedAction::new("/src/d.dv", "/dst/d.dpx", "DV", "DPX");
action.add_precondition(PreconditionCheck::skipped("optional", "tool unavailable"));
plan.add_action(action);
assert_eq!(plan.ready_count(), 1, "skipped check does not block");
}
#[test]
fn test_dry_run_plan_json_roundtrip() {
let mut plan = DryRunPlan::new("json-test");
plan.add_note("This is a test campaign");
let json = plan.to_json().expect("serialize");
let restored = DryRunPlan::from_json(&json).expect("deserialize");
assert_eq!(restored.campaign_name, "json-test");
assert_eq!(restored.global_notes.len(), 1);
}
#[test]
fn test_journal_empty() {
let journal = RollbackJournal::new("empty campaign");
assert_eq!(journal.entry_count(), 0);
assert!(journal.rollback_candidates().is_empty());
}
#[test]
fn test_journal_success_entry_with_backup_is_candidate() {
let mut journal = RollbackJournal::new("test");
let mut entry = JournalEntry::success("e1", "/src/a.dv", "/dst/a.dpx");
entry.backup_path = Some(PathBuf::from("/backup/a.dv"));
journal.record(entry);
assert_eq!(journal.rollback_candidates().len(), 1);
}
#[test]
fn test_journal_success_entry_without_backup_not_candidate() {
let mut journal = RollbackJournal::new("test");
let entry = JournalEntry::success("e2", "/src/b.dv", "/dst/b.dpx");
journal.record(entry);
assert_eq!(journal.rollback_candidates().len(), 0);
}
#[test]
fn test_journal_failed_entry_not_candidate() {
let mut journal = RollbackJournal::new("test");
let mut entry = JournalEntry::failed("e3", "/src/c.dv", "/dst/c.dpx", "transcode error");
entry.backup_path = Some(PathBuf::from("/backup/c.dv"));
journal.record(entry);
assert_eq!(
journal.rollback_candidates().len(),
0,
"failed actions cannot be rolled back"
);
}
#[test]
fn test_journal_mark_rolled_back_ok() {
let mut journal = RollbackJournal::new("test");
let mut entry = JournalEntry::success("e4", "/src/d.dv", "/dst/d.dpx");
entry.backup_path = Some(PathBuf::from("/backup/d.dv"));
journal.record(entry);
journal.mark_rolled_back("e4").expect("should rollback");
assert_eq!(journal.rolled_back_count(), 1);
assert_eq!(journal.success_count(), 0);
}
#[test]
fn test_journal_mark_rolled_back_unknown_id_errors() {
let mut journal = RollbackJournal::new("test");
let result = journal.mark_rolled_back("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_journal_build_rollback_plan() {
let mut journal = RollbackJournal::new("test");
let mut entry = JournalEntry::success("e5", "/src/e.dv", "/dst/e.dpx");
entry.backup_path = Some(PathBuf::from("/backup/e.dv"));
entry.source_checksum = Some("abc123".to_string());
journal.record(entry);
let plan = journal.build_rollback_plan();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].entry_id, "e5");
assert_eq!(plan[0].restore_from, PathBuf::from("/backup/e.dv"));
assert_eq!(plan[0].source_checksum.as_deref(), Some("abc123"));
}
#[test]
fn test_journal_json_roundtrip() {
let mut journal = RollbackJournal::new("json-roundtrip");
journal
.tags
.insert("operator".to_string(), "alice".to_string());
let json = journal.to_json().expect("serialize");
let restored = RollbackJournal::from_json(&json).expect("deserialize");
assert_eq!(restored.campaign_name, "json-roundtrip");
assert_eq!(
restored.tags.get("operator").map(|s| s.as_str()),
Some("alice")
);
}
#[test]
fn test_planned_action_default_flags() {
let action = PlannedAction::new("/src/a.dv", "/dst/a.mxf", "DV", "MXF");
assert!(!action.quality_loss, "quality_loss should default to false");
assert!(action.reversible, "reversible should default to true");
assert!(action.notes.is_empty(), "notes should default to empty");
assert!(
action.preconditions.is_empty(),
"preconditions should default to empty"
);
}
#[test]
fn test_planned_action_preconditions_satisfied_empty() {
let action = PlannedAction::new("/src/a.dv", "/dst/a.mxf", "DV", "MXF");
assert!(action.preconditions_satisfied());
}
#[test]
fn test_planned_action_multiple_ok_preconditions() {
let mut action = PlannedAction::new("/src/a.dv", "/dst/a.mxf", "DV", "MXF");
action.add_precondition(PreconditionCheck::ok("disk", "100 GB free"));
action.add_precondition(PreconditionCheck::ok("tool", "oximedia available"));
action.add_precondition(PreconditionCheck::skipped("gpu", "no GPU available"));
assert!(action.preconditions_satisfied());
}
#[test]
fn test_dry_run_plan_global_notes() {
let mut plan = DryRunPlan::new("notes-test");
plan.add_note("note 1");
plan.add_note("note 2");
assert_eq!(plan.global_notes.len(), 2);
assert_eq!(plan.global_notes[0], "note 1");
}
#[test]
fn test_dry_run_plan_estimated_bytes_only_ready() {
let mut plan = DryRunPlan::new("test");
let mut blocked = PlannedAction::new("/src/b.dv", "/dst/b.mxf", "DV", "MXF");
blocked.add_precondition(PreconditionCheck::failed("space", "no space"));
blocked.estimated_output_bytes = Some(500_000_000);
plan.add_action(blocked);
let mut ready = PlannedAction::new("/src/a.dv", "/dst/a.mxf", "DV", "MXF");
ready.estimated_output_bytes = Some(200_000_000);
plan.add_action(ready);
assert_eq!(plan.total_estimated_output_bytes(), 200_000_000);
}
#[test]
fn test_journal_entry_can_rollback_requires_success_and_backup() {
let mut e = JournalEntry::failed("e1", "/src/a.dv", "/dst/a.mxf", "error");
e.backup_path = Some(PathBuf::from("/backup/a.dv"));
assert!(!e.can_rollback());
let e2 = JournalEntry::success("e2", "/src/b.dv", "/dst/b.mxf");
assert!(!e2.can_rollback());
let mut e3 = JournalEntry::success("e3", "/src/c.dv", "/dst/c.mxf");
e3.backup_path = Some(PathBuf::from("/backup/c.dv"));
assert!(e3.can_rollback());
}
#[test]
fn test_journal_rollback_idempotent_state() {
let mut journal = RollbackJournal::new("test");
let mut entry = JournalEntry::success("e1", "/src/a.dv", "/dst/a.mxf");
entry.backup_path = Some(PathBuf::from("/backup/a.dv"));
journal.record(entry);
journal.mark_rolled_back("e1").expect("rollback ok");
assert_eq!(journal.rolled_back_count(), 1);
let second = journal.mark_rolled_back("e1");
assert!(
second.is_err(),
"cannot rollback an already-rolled-back entry"
);
}
#[test]
fn test_journal_multiple_entries_only_eligible_in_plan() {
let mut journal = RollbackJournal::new("test");
let mut e1 = JournalEntry::success("e1", "/src/a.dv", "/dst/a.mxf");
e1.backup_path = Some(PathBuf::from("/backup/a.dv"));
journal.record(e1);
journal.record(JournalEntry::success("e2", "/src/b.dv", "/dst/b.mxf"));
let mut e3 = JournalEntry::failed("e3", "/src/c.dv", "/dst/c.mxf", "error");
e3.backup_path = Some(PathBuf::from("/backup/c.dv"));
journal.record(e3);
assert_eq!(journal.rollback_candidates().len(), 1);
let plan = journal.build_rollback_plan();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].entry_id, "e1");
}
#[test]
fn test_rollback_step_fields() {
let mut journal = RollbackJournal::new("test");
let mut entry = JournalEntry::success("e42", "/src/master.mxf", "/dst/master.dpx");
entry.backup_path = Some(PathBuf::from("/vault/master.mxf.bak"));
entry.source_checksum = Some("blake3hex".to_string());
journal.record(entry);
let steps = journal.build_rollback_plan();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].entry_id, "e42");
assert_eq!(steps[0].migrated_path, PathBuf::from("/dst/master.dpx"));
assert_eq!(
steps[0].restore_from,
PathBuf::from("/vault/master.mxf.bak")
);
assert_eq!(steps[0].source_checksum.as_deref(), Some("blake3hex"));
}
#[test]
fn test_dry_run_plan_total_cpu_secs_only_ready() {
let mut plan = DryRunPlan::new("cpu-test");
let mut blocked = PlannedAction::new("/src/b.dv", "/dst/b.mxf", "DV", "MXF");
blocked.add_precondition(PreconditionCheck::failed("space", "no space"));
blocked.estimated_cpu_secs = Some(500.0);
plan.add_action(blocked);
let mut ready = PlannedAction::new("/src/a.dv", "/dst/a.mxf", "DV", "MXF");
ready.estimated_cpu_secs = Some(30.0);
plan.add_action(ready);
let total = plan.total_estimated_cpu_secs();
assert!(
(total - 30.0).abs() < 1e-9,
"blocked action CPU secs should not be counted"
);
}
}