#![allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum FormatRisk {
Preferred,
Stable,
AtRisk,
Endangered,
Obsolete,
}
impl FormatRisk {
#[must_use]
pub const fn priority(&self) -> u8 {
match self {
Self::Preferred => 0,
Self::Stable => 1,
Self::AtRisk => 2,
Self::Endangered => 3,
Self::Obsolete => 4,
}
}
#[must_use]
pub const fn label(&self) -> &str {
match self {
Self::Preferred => "preferred",
Self::Stable => "stable",
Self::AtRisk => "at_risk",
Self::Endangered => "endangered",
Self::Obsolete => "obsolete",
}
}
}
#[derive(Clone, Debug)]
pub struct FileFormat {
pub name: String,
pub extension: String,
pub mime_type: String,
pub risk: FormatRisk,
}
impl FileFormat {
#[must_use]
pub fn new(
name: impl Into<String>,
extension: impl Into<String>,
mime_type: impl Into<String>,
risk: FormatRisk,
) -> Self {
Self {
name: name.into(),
extension: extension.into(),
mime_type: mime_type.into(),
risk,
}
}
#[must_use]
pub fn dpx() -> Self {
Self::new("DPX", "dpx", "image/x-dpx", FormatRisk::Preferred)
}
#[must_use]
pub fn prores_4444() -> Self {
Self::new("ProRes 4444", "mov", "video/quicktime", FormatRisk::Stable)
}
#[must_use]
pub fn avid_dnxhd() -> Self {
Self::new("Avid DNxHD", "mxf", "application/mxf", FormatRisk::Stable)
}
#[must_use]
pub fn h264_mp4() -> Self {
Self::new("H.264/MP4", "mp4", "video/mp4", FormatRisk::Stable)
}
#[must_use]
pub fn mpeg2() -> Self {
Self::new("MPEG-2", "mpg", "video/mpeg", FormatRisk::AtRisk)
}
#[must_use]
pub fn dv() -> Self {
Self::new("DV", "dv", "video/x-dv", FormatRisk::Endangered)
}
#[must_use]
pub fn betacam() -> Self {
Self::new(
"Betacam SP",
"betacam",
"application/octet-stream",
FormatRisk::Obsolete,
)
}
}
#[derive(Clone, Debug)]
pub struct MigrationPath {
pub source: FileFormat,
pub target: FileFormat,
pub quality_loss: bool,
pub reversible: bool,
pub notes: String,
}
pub struct MigrationPlanner;
impl MigrationPlanner {
#[must_use]
pub fn plan_migration(source: &FileFormat) -> Option<MigrationPath> {
match source.risk {
FormatRisk::Preferred | FormatRisk::Stable => None,
FormatRisk::AtRisk => Some(MigrationPath {
source: source.clone(),
target: FileFormat::prores_4444(),
quality_loss: false,
reversible: false,
notes: "Migrate to ProRes 4444 for better long-term tool support.".to_string(),
}),
FormatRisk::Endangered => Some(MigrationPath {
source: source.clone(),
target: FileFormat::dpx(),
quality_loss: false,
reversible: false,
notes: "Urgent: migrate to DPX image sequence for lossless preservation."
.to_string(),
}),
FormatRisk::Obsolete => Some(MigrationPath {
source: source.clone(),
target: FileFormat::dpx(),
quality_loss: false,
reversible: false,
notes: "Critical: format is obsolete — immediate migration to DPX required."
.to_string(),
}),
}
}
#[must_use]
pub fn estimate_cost_gb(format: &FileFormat, size_gb: f64) -> f64 {
let cpu_hours_per_gb = match format.risk {
FormatRisk::Preferred => 0.1,
FormatRisk::Stable => 0.2,
FormatRisk::AtRisk => 0.5,
FormatRisk::Endangered => 1.5,
FormatRisk::Obsolete => 4.0,
};
cpu_hours_per_gb * size_gb
}
}
#[derive(Clone, Debug)]
pub struct MigrationBatch {
pub items: Vec<(String, MigrationPath)>,
pub total_size_gb: f64,
pub estimated_hours: f64,
}
impl MigrationBatch {
#[must_use]
pub fn new() -> Self {
Self {
items: Vec::new(),
total_size_gb: 0.0,
estimated_hours: 0.0,
}
}
pub fn add(&mut self, asset_id: impl Into<String>, path: MigrationPath, size_gb: f64) {
let cost = MigrationPlanner::estimate_cost_gb(&path.source, size_gb);
self.estimated_hours += cost;
self.total_size_gb += size_gb;
self.items.push((asset_id.into(), path));
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
impl Default for MigrationBatch {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_risk_priority_ordering() {
assert!(FormatRisk::Obsolete.priority() > FormatRisk::Preferred.priority());
assert!(FormatRisk::Endangered.priority() > FormatRisk::AtRisk.priority());
}
#[test]
fn test_format_risk_labels_non_empty() {
for risk in [
FormatRisk::Preferred,
FormatRisk::Stable,
FormatRisk::AtRisk,
FormatRisk::Endangered,
FormatRisk::Obsolete,
] {
assert!(!risk.label().is_empty());
}
}
#[test]
fn test_dpx_is_preferred() {
assert_eq!(FileFormat::dpx().risk, FormatRisk::Preferred);
}
#[test]
fn test_betacam_is_obsolete() {
assert_eq!(FileFormat::betacam().risk, FormatRisk::Obsolete);
}
#[test]
fn test_dv_is_endangered() {
assert_eq!(FileFormat::dv().risk, FormatRisk::Endangered);
}
#[test]
fn test_mpeg2_is_at_risk() {
assert_eq!(FileFormat::mpeg2().risk, FormatRisk::AtRisk);
}
#[test]
fn test_plan_migration_preferred_none() {
let dpx = FileFormat::dpx();
assert!(MigrationPlanner::plan_migration(&dpx).is_none());
}
#[test]
fn test_plan_migration_at_risk_some() {
let mpeg2 = FileFormat::mpeg2();
let path = MigrationPlanner::plan_migration(&mpeg2);
assert!(path.is_some());
let path = path.expect("path should be valid");
assert_eq!(path.target.risk, FormatRisk::Stable);
}
#[test]
fn test_plan_migration_obsolete_to_dpx() {
let betacam = FileFormat::betacam();
let path = MigrationPlanner::plan_migration(&betacam).expect("path should be valid");
assert_eq!(path.target.extension, "dpx");
}
#[test]
fn test_estimate_cost_gb_preferred_cheap() {
let dpx = FileFormat::dpx();
let cost = MigrationPlanner::estimate_cost_gb(&dpx, 10.0);
assert!(cost <= 1.0, "cost = {cost}");
}
#[test]
fn test_estimate_cost_gb_obsolete_expensive() {
let betacam = FileFormat::betacam();
let cost = MigrationPlanner::estimate_cost_gb(&betacam, 10.0);
assert!(cost > 10.0, "cost = {cost}");
}
#[test]
fn test_migration_batch_add() {
let mut batch = MigrationBatch::new();
let dv = FileFormat::dv();
let path = MigrationPlanner::plan_migration(&dv).expect("path should be valid");
batch.add("asset-001", path, 5.0);
assert_eq!(batch.len(), 1);
assert!((batch.total_size_gb - 5.0).abs() < 1e-9);
assert!(batch.estimated_hours > 0.0);
}
#[test]
fn test_migration_batch_is_empty() {
let batch = MigrationBatch::new();
assert!(batch.is_empty());
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum MigrationRisk {
Low,
Medium,
High,
Critical,
}
impl MigrationRisk {
#[must_use]
pub const fn score(self) -> u32 {
match self {
Self::Low => 1,
Self::Medium => 2,
Self::High => 3,
Self::Critical => 4,
}
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatSupport {
FullySupported,
LegacySupport,
DeprecatedSoon,
Unsupported,
}
impl FormatSupport {
#[must_use]
pub const fn migration_risk(self) -> MigrationRisk {
match self {
Self::FullySupported => MigrationRisk::Low,
Self::LegacySupport => MigrationRisk::Medium,
Self::DeprecatedSoon => MigrationRisk::High,
Self::Unsupported => MigrationRisk::Critical,
}
}
}
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct MigrationTask {
pub source_format: String,
pub target_format: String,
pub file_count: u32,
pub estimated_hours: f32,
}
impl MigrationTask {
#[must_use]
pub fn is_large(&self) -> bool {
self.file_count > 1_000
}
}
#[allow(dead_code)]
#[derive(Default, Debug)]
pub struct MigrationPlan {
pub tasks: Vec<MigrationTask>,
pub description: String,
}
impl MigrationPlan {
#[must_use]
pub fn new(description: impl Into<String>) -> Self {
Self {
tasks: Vec::new(),
description: description.into(),
}
}
pub fn add(&mut self, task: MigrationTask) {
self.tasks.push(task);
}
#[must_use]
pub fn total_files(&self) -> u32 {
self.tasks.iter().map(|t| t.file_count).sum()
}
#[must_use]
pub fn total_hours(&self) -> f32 {
self.tasks.iter().map(|t| t.estimated_hours).sum()
}
#[must_use]
pub fn high_risk_tasks(&self) -> Vec<&MigrationTask> {
self.tasks
.iter()
.filter(|t| t.is_large() || t.estimated_hours > 24.0)
.collect()
}
}
#[cfg(test)]
mod migration_plan_tests {
use super::*;
fn small_task(src: &str, tgt: &str, n: u32, h: f32) -> MigrationTask {
MigrationTask {
source_format: src.to_string(),
target_format: tgt.to_string(),
file_count: n,
estimated_hours: h,
}
}
#[test]
fn test_migration_risk_score_ordering() {
assert!(MigrationRisk::Critical.score() > MigrationRisk::High.score());
assert!(MigrationRisk::High.score() > MigrationRisk::Medium.score());
assert!(MigrationRisk::Medium.score() > MigrationRisk::Low.score());
}
#[test]
fn test_migration_risk_score_values() {
assert_eq!(MigrationRisk::Low.score(), 1);
assert_eq!(MigrationRisk::Critical.score(), 4);
}
#[test]
fn test_format_support_fully_supported_low_risk() {
assert_eq!(
FormatSupport::FullySupported.migration_risk(),
MigrationRisk::Low
);
}
#[test]
fn test_format_support_unsupported_critical_risk() {
assert_eq!(
FormatSupport::Unsupported.migration_risk(),
MigrationRisk::Critical
);
}
#[test]
fn test_format_support_deprecated_high_risk() {
assert_eq!(
FormatSupport::DeprecatedSoon.migration_risk(),
MigrationRisk::High
);
}
#[test]
fn test_migration_task_is_large_false() {
let t = small_task("DV", "DPX", 500, 5.0);
assert!(!t.is_large());
}
#[test]
fn test_migration_task_is_large_true() {
let t = small_task("DV", "DPX", 2_000, 40.0);
assert!(t.is_large());
}
#[test]
fn test_migration_plan_total_files() {
let mut plan = MigrationPlan::new("Q1 migration");
plan.add(small_task("A", "B", 100, 2.0));
plan.add(small_task("C", "D", 200, 4.0));
assert_eq!(plan.total_files(), 300);
}
#[test]
fn test_migration_plan_total_hours() {
let mut plan = MigrationPlan::new("test");
plan.add(small_task("A", "B", 10, 1.5));
plan.add(small_task("C", "D", 10, 2.5));
assert!((plan.total_hours() - 4.0).abs() < 1e-6);
}
#[test]
fn test_migration_plan_high_risk_by_file_count() {
let mut plan = MigrationPlan::new("big batch");
plan.add(small_task("DV", "DPX", 5_000, 10.0));
plan.add(small_task("MP4", "MOV", 50, 1.0));
let hr = plan.high_risk_tasks();
assert_eq!(hr.len(), 1);
assert_eq!(hr[0].source_format, "DV");
}
#[test]
fn test_migration_plan_high_risk_by_hours() {
let mut plan = MigrationPlan::new("long job");
plan.add(small_task("old", "new", 100, 30.0));
assert_eq!(plan.high_risk_tasks().len(), 1);
}
#[test]
fn test_migration_plan_no_high_risk() {
let mut plan = MigrationPlan::new("easy");
plan.add(small_task("A", "B", 50, 2.0));
assert!(plan.high_risk_tasks().is_empty());
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DryRunOutcome {
WouldSucceed,
WouldFail(String),
Skipped(String),
}
impl DryRunOutcome {
#[must_use]
pub fn is_success(&self) -> bool {
matches!(self, Self::WouldSucceed)
}
#[must_use]
pub fn is_failure(&self) -> bool {
matches!(self, Self::WouldFail(_))
}
}
impl std::fmt::Display for DryRunOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WouldSucceed => write!(f, "WOULD_SUCCEED"),
Self::WouldFail(reason) => write!(f, "WOULD_FAIL: {reason}"),
Self::Skipped(reason) => write!(f, "SKIPPED: {reason}"),
}
}
}
#[derive(Clone, Debug)]
pub struct DryRunEntry {
pub asset_id: String,
pub source_format: String,
pub target_format: String,
pub outcome: DryRunOutcome,
pub estimated_hours: f64,
pub estimated_output_size_gb: f64,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct DryRunReport {
pub plan_description: String,
pub entries: Vec<DryRunEntry>,
pub total_estimated_hours: f64,
pub total_estimated_size_gb: f64,
}
impl DryRunReport {
#[must_use]
pub fn success_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.outcome.is_success())
.count()
}
#[must_use]
pub fn failure_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.outcome.is_failure())
.count()
}
#[must_use]
pub fn skipped_count(&self) -> usize {
self.entries
.iter()
.filter(|e| matches!(e.outcome, DryRunOutcome::Skipped(_)))
.count()
}
#[must_use]
pub fn all_warnings(&self) -> Vec<&str> {
self.entries
.iter()
.flat_map(|e| e.warnings.iter().map(|w| w.as_str()))
.collect()
}
#[must_use]
pub fn passed(&self) -> bool {
self.failure_count() == 0
}
}
#[derive(Clone, Debug)]
pub struct DryRunAsset {
pub asset_id: String,
pub source: FileFormat,
pub size_gb: f64,
pub is_readable: bool,
pub has_disk_space: bool,
}
pub fn dry_run_migration(assets: &[DryRunAsset], plan_description: &str) -> DryRunReport {
let mut entries = Vec::with_capacity(assets.len());
let mut total_hours = 0.0;
let mut total_size = 0.0;
for asset in assets {
let migration_path = MigrationPlanner::plan_migration(&asset.source);
let (outcome, target_format, est_hours, est_size, warnings) = match migration_path {
None => {
let reason = format!(
"format '{}' is {} — no migration needed",
asset.source.name,
asset.source.risk.label()
);
(
DryRunOutcome::Skipped(reason),
asset.source.name.clone(),
0.0,
0.0,
Vec::new(),
)
}
Some(ref path) => {
let mut warns = Vec::new();
if !asset.is_readable {
(
DryRunOutcome::WouldFail(format!(
"source file for asset '{}' is not readable",
asset.asset_id
)),
path.target.name.clone(),
0.0,
0.0,
warns,
)
} else if !asset.has_disk_space {
(
DryRunOutcome::WouldFail(format!(
"insufficient disk space for asset '{}'",
asset.asset_id
)),
path.target.name.clone(),
0.0,
0.0,
warns,
)
} else {
let hours = MigrationPlanner::estimate_cost_gb(&asset.source, asset.size_gb);
let output_size = asset.size_gb * if path.quality_loss { 0.3 } else { 1.05 };
if path.quality_loss {
warns.push("migration involves quality loss".to_string());
}
if asset.size_gb > 100.0 {
warns.push(format!(
"large asset ({:.1} GB) — migration may take {:.1} hours",
asset.size_gb, hours
));
}
if asset.source.risk == FormatRisk::Obsolete {
warns.push(
"source format is OBSOLETE — verify migration output carefully"
.to_string(),
);
}
(
DryRunOutcome::WouldSucceed,
path.target.name.clone(),
hours,
output_size,
warns,
)
}
}
};
total_hours += est_hours;
total_size += est_size;
entries.push(DryRunEntry {
asset_id: asset.asset_id.clone(),
source_format: asset.source.name.clone(),
target_format,
outcome,
estimated_hours: est_hours,
estimated_output_size_gb: est_size,
warnings,
});
}
DryRunReport {
plan_description: plan_description.to_string(),
entries,
total_estimated_hours: total_hours,
total_estimated_size_gb: total_size,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MigrationStepStatus {
Pending,
InProgress,
Completed,
Failed(String),
RolledBack,
}
impl std::fmt::Display for MigrationStepStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "PENDING"),
Self::InProgress => write!(f, "IN_PROGRESS"),
Self::Completed => write!(f, "COMPLETED"),
Self::Failed(reason) => write!(f, "FAILED: {reason}"),
Self::RolledBack => write!(f, "ROLLED_BACK"),
}
}
}
#[derive(Clone, Debug)]
pub struct MigrationStep {
pub step_id: String,
pub asset_id: String,
pub source_path: String,
pub backup_path: Option<String>,
pub target_path: String,
pub source_format: String,
pub target_format: String,
pub status: MigrationStepStatus,
}
#[derive(Debug, Default)]
pub struct RollbackJournal {
steps: Vec<MigrationStep>,
}
impl RollbackJournal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_step(&mut self, step: MigrationStep) {
self.steps.push(step);
}
pub fn mark_in_progress(&mut self, step_id: &str) -> bool {
if let Some(step) = self.steps.iter_mut().find(|s| s.step_id == step_id) {
step.status = MigrationStepStatus::InProgress;
true
} else {
false
}
}
pub fn mark_completed(&mut self, step_id: &str, backup_path: Option<String>) -> bool {
if let Some(step) = self.steps.iter_mut().find(|s| s.step_id == step_id) {
step.status = MigrationStepStatus::Completed;
step.backup_path = backup_path;
true
} else {
false
}
}
pub fn mark_failed(&mut self, step_id: &str, reason: &str) -> bool {
if let Some(step) = self.steps.iter_mut().find(|s| s.step_id == step_id) {
step.status = MigrationStepStatus::Failed(reason.to_string());
true
} else {
false
}
}
pub fn mark_rolled_back(&mut self, step_id: &str) -> bool {
if let Some(step) = self.steps.iter_mut().find(|s| s.step_id == step_id) {
step.status = MigrationStepStatus::RolledBack;
true
} else {
false
}
}
#[must_use]
pub fn completed_steps(&self) -> Vec<&MigrationStep> {
self.steps
.iter()
.filter(|s| s.status == MigrationStepStatus::Completed)
.collect()
}
#[must_use]
pub fn steps_needing_rollback(&self) -> Vec<&MigrationStep> {
self.steps
.iter()
.filter(|s| {
matches!(
s.status,
MigrationStepStatus::Completed | MigrationStepStatus::InProgress
)
})
.rev() .collect()
}
#[must_use]
pub fn step_count(&self) -> usize {
self.steps.len()
}
#[must_use]
pub fn get_step(&self, step_id: &str) -> Option<&MigrationStep> {
self.steps.iter().find(|s| s.step_id == step_id)
}
#[must_use]
pub fn summary(&self) -> RollbackSummary {
let mut pending = 0;
let mut in_progress = 0;
let mut completed = 0;
let mut failed = 0;
let mut rolled_back = 0;
for step in &self.steps {
match &step.status {
MigrationStepStatus::Pending => pending += 1,
MigrationStepStatus::InProgress => in_progress += 1,
MigrationStepStatus::Completed => completed += 1,
MigrationStepStatus::Failed(_) => failed += 1,
MigrationStepStatus::RolledBack => rolled_back += 1,
}
}
RollbackSummary {
total: self.steps.len(),
pending,
in_progress,
completed,
failed,
rolled_back,
}
}
}
#[derive(Debug, Clone)]
pub struct RollbackSummary {
pub total: usize,
pub pending: usize,
pub in_progress: usize,
pub completed: usize,
pub failed: usize,
pub rolled_back: usize,
}
impl RollbackSummary {
#[must_use]
pub fn all_succeeded(&self) -> bool {
self.failed == 0 && self.pending == 0 && self.in_progress == 0
}
}
#[cfg(test)]
mod dry_run_rollback_tests {
use super::*;
fn make_asset(
id: &str,
format: FileFormat,
size_gb: f64,
readable: bool,
space: bool,
) -> DryRunAsset {
DryRunAsset {
asset_id: id.to_string(),
source: format,
size_gb,
is_readable: readable,
has_disk_space: space,
}
}
#[test]
fn test_dry_run_preferred_format_skipped() {
let assets = vec![make_asset("a1", FileFormat::dpx(), 10.0, true, true)];
let report = dry_run_migration(&assets, "test");
assert_eq!(report.skipped_count(), 1);
assert_eq!(report.success_count(), 0);
assert!(report.passed());
}
#[test]
fn test_dry_run_at_risk_succeeds() {
let assets = vec![make_asset("a2", FileFormat::mpeg2(), 5.0, true, true)];
let report = dry_run_migration(&assets, "migrate mpeg2");
assert_eq!(report.success_count(), 1);
assert!(report.total_estimated_hours > 0.0);
assert!(report.total_estimated_size_gb > 0.0);
assert!(report.passed());
}
#[test]
fn test_dry_run_unreadable_fails() {
let assets = vec![make_asset("a3", FileFormat::dv(), 1.0, false, true)];
let report = dry_run_migration(&assets, "test");
assert_eq!(report.failure_count(), 1);
assert!(!report.passed());
}
#[test]
fn test_dry_run_no_disk_space_fails() {
let assets = vec![make_asset("a4", FileFormat::dv(), 1.0, true, false)];
let report = dry_run_migration(&assets, "test");
assert_eq!(report.failure_count(), 1);
assert!(!report.passed());
}
#[test]
fn test_dry_run_mixed_results() {
let assets = vec![
make_asset("ok", FileFormat::mpeg2(), 2.0, true, true),
make_asset("skip", FileFormat::dpx(), 3.0, true, true),
make_asset("fail", FileFormat::dv(), 1.0, false, true),
];
let report = dry_run_migration(&assets, "mixed");
assert_eq!(report.success_count(), 1);
assert_eq!(report.skipped_count(), 1);
assert_eq!(report.failure_count(), 1);
assert!(!report.passed());
}
#[test]
fn test_dry_run_large_asset_warning() {
let assets = vec![make_asset("big", FileFormat::mpeg2(), 200.0, true, true)];
let report = dry_run_migration(&assets, "big batch");
let warnings = report.all_warnings();
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.contains("large asset")));
}
#[test]
fn test_dry_run_obsolete_format_warning() {
let assets = vec![make_asset("old", FileFormat::betacam(), 10.0, true, true)];
let report = dry_run_migration(&assets, "obsolete");
let warnings = report.all_warnings();
assert!(warnings.iter().any(|w| w.contains("OBSOLETE")));
}
#[test]
fn test_dry_run_empty_assets() {
let report = dry_run_migration(&[], "empty");
assert!(report.passed());
assert_eq!(report.entries.len(), 0);
assert!((report.total_estimated_hours).abs() < 1e-10);
}
#[test]
fn test_dry_run_outcome_display() {
assert_eq!(DryRunOutcome::WouldSucceed.to_string(), "WOULD_SUCCEED");
assert!(DryRunOutcome::WouldFail("reason".into())
.to_string()
.contains("reason"));
assert!(DryRunOutcome::Skipped("ok".into())
.to_string()
.contains("ok"));
}
fn make_step(id: &str, asset: &str) -> MigrationStep {
MigrationStep {
step_id: id.to_string(),
asset_id: asset.to_string(),
source_path: format!("/source/{asset}.dv"),
backup_path: None,
target_path: format!("/target/{asset}.dpx"),
source_format: "DV".to_string(),
target_format: "DPX".to_string(),
status: MigrationStepStatus::Pending,
}
}
#[test]
fn test_rollback_journal_record_and_query() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "video_a"));
journal.record_step(make_step("s2", "video_b"));
assert_eq!(journal.step_count(), 2);
}
#[test]
fn test_rollback_journal_lifecycle() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "video_a"));
assert!(journal.mark_in_progress("s1"));
let step = journal.get_step("s1");
assert_eq!(
step.map(|s| &s.status),
Some(&MigrationStepStatus::InProgress)
);
assert!(journal.mark_completed("s1", Some("/backup/video_a.dv".to_string())));
let step = journal.get_step("s1");
assert_eq!(
step.map(|s| &s.status),
Some(&MigrationStepStatus::Completed)
);
assert_eq!(
step.and_then(|s| s.backup_path.as_deref()),
Some("/backup/video_a.dv")
);
}
#[test]
fn test_rollback_journal_failed_step() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "video_a"));
assert!(journal.mark_in_progress("s1"));
assert!(journal.mark_failed("s1", "disk full"));
let step = journal.get_step("s1");
assert!(matches!(
step.map(|s| &s.status),
Some(MigrationStepStatus::Failed(_))
));
}
#[test]
fn test_rollback_journal_steps_needing_rollback() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "a"));
journal.record_step(make_step("s2", "b"));
journal.record_step(make_step("s3", "c"));
journal.mark_in_progress("s1");
journal.mark_completed("s1", None);
journal.mark_in_progress("s2");
journal.mark_completed("s2", None);
journal.mark_in_progress("s3");
let needing = journal.steps_needing_rollback();
assert_eq!(needing.len(), 3);
assert_eq!(needing[0].step_id, "s3");
assert_eq!(needing[1].step_id, "s2");
assert_eq!(needing[2].step_id, "s1");
}
#[test]
fn test_rollback_journal_mark_rolled_back() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "a"));
journal.mark_in_progress("s1");
journal.mark_completed("s1", None);
assert!(journal.mark_rolled_back("s1"));
let step = journal.get_step("s1");
assert_eq!(
step.map(|s| &s.status),
Some(&MigrationStepStatus::RolledBack)
);
}
#[test]
fn test_rollback_journal_summary() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "a"));
journal.record_step(make_step("s2", "b"));
journal.record_step(make_step("s3", "c"));
journal.record_step(make_step("s4", "d"));
journal.mark_in_progress("s1");
journal.mark_completed("s1", None);
journal.mark_in_progress("s2");
journal.mark_failed("s2", "error");
journal.mark_in_progress("s3");
journal.mark_completed("s3", None);
journal.mark_rolled_back("s3");
let summary = journal.summary();
assert_eq!(summary.total, 4);
assert_eq!(summary.completed, 1);
assert_eq!(summary.failed, 1);
assert_eq!(summary.rolled_back, 1);
assert_eq!(summary.pending, 1);
assert!(!summary.all_succeeded());
}
#[test]
fn test_rollback_summary_all_succeeded() {
let mut journal = RollbackJournal::new();
journal.record_step(make_step("s1", "a"));
journal.mark_in_progress("s1");
journal.mark_completed("s1", None);
let summary = journal.summary();
assert!(summary.all_succeeded());
}
#[test]
fn test_rollback_journal_missing_step_id() {
let mut journal = RollbackJournal::new();
assert!(!journal.mark_in_progress("nonexistent"));
assert!(!journal.mark_completed("nonexistent", None));
assert!(!journal.mark_failed("nonexistent", "err"));
assert!(!journal.mark_rolled_back("nonexistent"));
}
#[test]
fn test_migration_step_status_display() {
assert_eq!(MigrationStepStatus::Pending.to_string(), "PENDING");
assert_eq!(MigrationStepStatus::InProgress.to_string(), "IN_PROGRESS");
assert_eq!(MigrationStepStatus::Completed.to_string(), "COMPLETED");
assert_eq!(MigrationStepStatus::RolledBack.to_string(), "ROLLED_BACK");
assert!(MigrationStepStatus::Failed("err".into())
.to_string()
.contains("err"));
}
}