use crate::json_file_store::JsonEnvelope;
use kanban_persistence::{FormatVersion, PersistenceError, PersistenceResult};
use serde_json::Value;
use std::path::Path;
pub struct Migrator;
impl Migrator {
pub fn detect_version_from_value(value: &Value) -> PersistenceResult<FormatVersion> {
if let Some(version) = value.get("version").and_then(|v| v.as_u64()) {
let v: u32 = u32::try_from(version).unwrap_or(u32::MAX);
return FormatVersion::from_u32(v).ok_or(PersistenceError::UnsupportedFutureVersion {
file_version: v,
binary_max: FormatVersion::MAX.as_u32(),
});
}
if value.get("boards").is_some() {
return Ok(FormatVersion::V1);
}
Ok(FormatVersion::V2)
}
pub async fn detect_version(path: &Path) -> PersistenceResult<FormatVersion> {
if !path.exists() {
return Ok(FormatVersion::MAX); }
let content = tokio::fs::read_to_string(path).await?;
let value: Value = serde_json::from_str(&content)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
Self::detect_version_from_value(&value)
}
pub async fn migrate(
from: FormatVersion,
to: FormatVersion,
path: &Path,
) -> PersistenceResult<()> {
if from == to {
return Ok(());
}
match (from, to) {
(FormatVersion::V1, FormatVersion::V2) => Self::migrate_v1_to_v2(path).await,
(FormatVersion::V1, FormatVersion::V3) => {
Self::migrate_v1_to_v2(path).await?;
super::v2_to_v3::migrate_v2_to_v3(path).await
}
(FormatVersion::V2, FormatVersion::V3) => super::v2_to_v3::migrate_v2_to_v3(path).await,
(_, FormatVersion::V7) if from < FormatVersion::V7 => {
let backup_path = super::pre_v7_backup_path_for(from, path);
if let Some(backup) = &backup_path {
tokio::fs::copy(path, backup).await?;
tracing::info!("Created pre-V7 backup at {}", backup.display());
}
let result: PersistenceResult<()> = async {
if from == FormatVersion::V1 {
Self::migrate_v1_to_v2(path).await?;
}
if from <= FormatVersion::V2 {
super::v2_to_v3::migrate_v2_to_v3(path).await?;
}
Self::run_split_and_rename_chain(from, path).await
}
.await;
match (result, backup_path) {
(Ok(()), Some(backup)) => {
if let Err(e) = tokio::fs::remove_file(&backup).await {
tracing::warn!(
"Migration successful but failed to remove backup at {}: {}",
backup.display(),
e
);
} else {
tracing::info!("Migration to V7 verified, backup removed");
}
Ok(())
}
(Ok(()), None) => Ok(()),
(Err(e), Some(backup)) => {
tracing::error!(
"Migration to V7 failed: {}. Backup preserved at {}",
e,
backup.display()
);
Err(e)
}
(Err(e), None) => Err(e),
}
}
_ => Err(PersistenceError::Serialization(format!(
"Unsupported migration: {:?} -> {:?}",
from, to
))),
}
}
async fn run_split_and_rename_chain(from: FormatVersion, path: &Path) -> PersistenceResult<()> {
if from < FormatVersion::V6 {
super::split_graph::migrate_to_v6_split_graph(path).await?;
}
super::v6_to_v7_rename::migrate_v6_to_v7(path).await
}
async fn migrate_v1_to_v2(path: &Path) -> PersistenceResult<()> {
let content = tokio::fs::read_to_string(path).await?;
let v1_data: Value = serde_json::from_str(&content)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let v2_envelope = JsonEnvelope::new(v1_data.clone());
let json_str = v2_envelope
.to_json_string()
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
tokio::fs::write(path, json_str).await?;
tracing::info!("Migrated {} from V1 to V2 format", path.display());
Self::verify_migration(path, &v1_data).await
}
async fn verify_migration(path: &Path, original_data: &Value) -> PersistenceResult<()> {
let migrated_content = tokio::fs::read_to_string(path).await?;
let migrated_value: Value = serde_json::from_str(&migrated_content).map_err(|e| {
PersistenceError::Serialization(format!("Failed to parse migrated file: {}", e))
})?;
migrated_value
.get("version")
.and_then(|v| v.as_u64())
.filter(|&v| v == 2)
.ok_or_else(|| {
PersistenceError::Serialization(
"Migrated file missing or invalid version field".to_string(),
)
})?;
migrated_value
.get("metadata")
.filter(|m| m.is_object())
.ok_or_else(|| {
PersistenceError::Serialization(
"Migrated file missing or invalid metadata field".to_string(),
)
})?;
let migrated_data = migrated_value.get("data").ok_or_else(|| {
PersistenceError::Serialization("Migrated file missing data field".to_string())
})?;
if migrated_data != original_data {
return Err(PersistenceError::Serialization(
"Migrated data does not match original data".to_string(),
));
}
tracing::debug!("Migration verification passed");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_detect_v1_format() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.json");
let v1_data = json!({
"boards": [],
"columns": [],
"cards": []
});
tokio::fs::write(&file_path, v1_data.to_string())
.await
.unwrap();
let version = Migrator::detect_version(&file_path).await.unwrap();
assert_eq!(version, FormatVersion::V1);
}
#[tokio::test]
async fn test_detect_v2_format() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.json");
let v2_data = json!({
"version": 2,
"metadata": {},
"data": {}
});
tokio::fs::write(&file_path, v2_data.to_string())
.await
.unwrap();
let version = Migrator::detect_version(&file_path).await.unwrap();
assert_eq!(version, FormatVersion::V2);
}
#[tokio::test]
async fn test_migrate_v1_to_v2() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.json");
let v1_data = json!({
"boards": [{ "id": "1", "name": "Test Board" }],
"columns": [],
"cards": []
});
tokio::fs::write(&file_path, v1_data.to_string())
.await
.unwrap();
Migrator::migrate(FormatVersion::V1, FormatVersion::V2, &file_path)
.await
.unwrap();
let migrated = tokio::fs::read_to_string(&file_path).await.unwrap();
let v2_data: Value = serde_json::from_str(&migrated).unwrap();
assert_eq!(v2_data["version"], 2);
assert!(v2_data["metadata"].is_object());
assert!(v2_data["data"]["boards"].is_array());
assert!(
!file_path.with_extension("v1.backup").exists(),
"Backup should be removed after successful migration"
);
}
#[tokio::test]
async fn test_migrate_v7_to_v7_is_a_noop_byte_for_byte() {
let dir = tempdir().unwrap();
let path = dir.path().join("v7.json");
let v7 = json!({
"version": 7,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [], "columns": [], "cards": [], "archived_cards": [], "sprints": [],
"graph": {
"spawns": { "edges": [] },
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
let original = serde_json::to_string_pretty(&v7).unwrap();
tokio::fs::write(&path, &original).await.unwrap();
Migrator::migrate(FormatVersion::V7, FormatVersion::V7, &path)
.await
.unwrap();
let after = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(
after, original,
"V7 -> V7 migration must be a byte-for-byte noop"
);
}
#[tokio::test]
async fn test_migrate_v4_to_v7_skips_legacy_steps() {
let dir = tempdir().unwrap();
let path = dir.path().join("v4.json");
let v4 = json!({
"version": 4,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [],
"columns": [],
"cards": [],
"archived_cards": [],
"sprints": [],
"graph": { "cards": { "edges": [] } }
}
});
tokio::fs::write(&path, serde_json::to_string_pretty(&v4).unwrap())
.await
.unwrap();
Migrator::migrate(FormatVersion::V4, FormatVersion::V7, &path)
.await
.unwrap();
let after: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(after["data"]["graph"]["spawns"].is_object());
assert!(
after["data"]["graph"]
.as_object()
.unwrap()
.get("parent_child")
.is_none(),
"post-v7 graph must not retain the legacy parent_child key"
);
assert!(
!path.with_extension("v1.backup").exists(),
"V4 -> V7 must not trigger the v1→v2 step that creates .v1.backup"
);
}
#[tokio::test]
async fn test_migrate_v5_to_v7_skips_legacy_steps() {
let dir = tempdir().unwrap();
let path = dir.path().join("v5.json");
let v5 = json!({
"version": 5,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [],
"columns": [],
"cards": [],
"archived_cards": [],
"sprints": [],
"graph": { "cards": { "edges": [] } }
}
});
tokio::fs::write(&path, serde_json::to_string_pretty(&v5).unwrap())
.await
.unwrap();
Migrator::migrate(FormatVersion::V5, FormatVersion::V7, &path)
.await
.unwrap();
let after: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(after["data"]["graph"]["spawns"].is_object());
assert!(after["data"]["graph"]["relates"].is_object());
assert!(
!path.with_extension("v1.backup").exists(),
"V5 -> V7 must not trigger the v1→v2 step that creates .v1.backup"
);
}
#[tokio::test]
async fn test_migrate_v6_to_v7_renames_parent_child_and_writes_backup() {
let dir = tempdir().unwrap();
let path = dir.path().join("v6.json");
let v6 = json!({
"version": 6,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [], "columns": [], "cards": [], "archived_cards": [], "sprints": [],
"graph": {
"parent_child": { "edges": [{
"source": "11111111-1111-1111-1111-111111111111",
"target": "22222222-2222-2222-2222-222222222222",
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
}]},
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
tokio::fs::write(&path, serde_json::to_string_pretty(&v6).unwrap())
.await
.unwrap();
Migrator::migrate(FormatVersion::V6, FormatVersion::V7, &path)
.await
.unwrap();
let after: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(after["data"]["graph"]["spawns"].is_object());
assert!(after["data"]["graph"]
.as_object()
.unwrap()
.get("parent_child")
.is_none());
assert_eq!(
after["data"]["graph"]["spawns"]["edges"]
.as_array()
.unwrap()
.len(),
1,
"the original parent_child edge must survive"
);
assert!(
!path.with_extension("v6.backup").exists(),
"v6.backup should be removed after successful migration to V7"
);
}
#[tokio::test]
async fn test_migrate_v6_to_v7_preserves_v6_backup_on_failure() {
let dir = tempdir().unwrap();
let path = dir.path().join("v6_ambiguous.json");
let v6 = json!({
"version": 6,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [], "columns": [], "cards": [], "archived_cards": [], "sprints": [],
"graph": {
"parent_child": { "edges": [{
"source": "11111111-1111-1111-1111-111111111111",
"target": "22222222-2222-2222-2222-222222222222",
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
}]},
"spawns": { "edges": [{
"source": "33333333-3333-3333-3333-333333333333",
"target": "44444444-4444-4444-4444-444444444444",
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
}]},
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
tokio::fs::write(&path, serde_json::to_string_pretty(&v6).unwrap())
.await
.unwrap();
let err = Migrator::migrate(FormatVersion::V6, FormatVersion::V7, &path)
.await
.expect_err("migration must refuse a V6 envelope carrying both bucket keys");
let msg = err.to_string();
assert!(
msg.contains("parent_child") && msg.contains("spawns"),
"diagnostic should name both colliding keys; got: {msg}"
);
assert!(
path.with_extension("v6.backup").exists(),
".v6.backup must be preserved when the V6→V7 step fails so the user can recover"
);
}
#[tokio::test]
async fn test_detect_version_from_value_rejects_future_version() {
let v99 = json!({
"version": 99,
"metadata": {},
"data": {}
});
let err = Migrator::detect_version_from_value(&v99).expect_err("v99 must be refused");
assert!(
matches!(
err,
PersistenceError::UnsupportedFutureVersion {
file_version: 99,
binary_max: 7
}
),
"expected UnsupportedFutureVersion, got: {err:?}"
);
}
#[test]
fn test_detect_version_from_value_rejects_u32_overflow_that_truncates_to_valid_version() {
let truncates_to_v7 = json!({
"version": (1u64 << 32) + 7,
"metadata": {},
"data": {}
});
let err = Migrator::detect_version_from_value(&truncates_to_v7)
.expect_err("version > u32::MAX must be refused, not truncated to V7");
assert!(
matches!(
err,
PersistenceError::UnsupportedFutureVersion { binary_max: 7, .. }
),
"expected UnsupportedFutureVersion, got: {err:?}"
);
}
#[tokio::test]
async fn test_detect_version_async_rejects_future_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("v99.json");
let v99 = json!({
"version": 99,
"metadata": {},
"data": {}
});
tokio::fs::write(&path, v99.to_string()).await.unwrap();
let err = Migrator::detect_version(&path)
.await
.expect_err("v99 must be refused");
assert!(
matches!(
err,
PersistenceError::UnsupportedFutureVersion {
file_version: 99,
binary_max: 7
}
),
"expected UnsupportedFutureVersion, got: {err:?}"
);
}
#[tokio::test]
async fn test_migrate_v1_to_v3_via_chain() {
let dir = tempdir().unwrap();
let path = dir.path().join("board.json");
let v1_content = json!({
"boards": [{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Test",
"card_prefix": "KAN",
"prefix_counters": { "KAN": 3 },
"sprint_counters": {},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}],
"columns": [{ "id": "550e8400-e29b-41d4-a716-446655440002",
"board_id": "550e8400-e29b-41d4-a716-446655440001" }],
"cards": [{
"id": "550e8400-e29b-41d4-a716-446655440003",
"column_id": "550e8400-e29b-41d4-a716-446655440002",
"card_number": 1,
"assigned_prefix": "KAN",
"card_prefix": "KAN"
}],
"archived_cards": [],
"sprints": []
});
tokio::fs::write(&path, v1_content.to_string())
.await
.unwrap();
Migrator::migrate(FormatVersion::V1, FormatVersion::V3, &path)
.await
.expect("V1→V3 migration must succeed");
let result: serde_json::Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(result["version"], 3, "output version must be 3");
assert!(result["metadata"].is_object());
let board = &result["data"]["boards"][0];
assert!(
board.get("prefix_counters").is_none(),
"prefix_counters must be stripped"
);
assert_eq!(
board["card_counter"], 3,
"card_counter derived from prefix_counters[KAN]"
);
let card = &result["data"]["cards"][0];
assert!(
card.get("assigned_prefix").is_none(),
"assigned_prefix must be stripped"
);
assert!(
card.get("card_prefix").is_none(),
"card_prefix must be stripped"
);
assert_eq!(card["card_number"], 1);
}
#[tokio::test]
async fn test_migrate_v2_to_v7_cleans_up_v2_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v2.json");
let v2 = json!({
"version": 2,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [], "columns": [], "cards": [], "archived_cards": [], "sprints": []
}
});
tokio::fs::write(&path, serde_json::to_string_pretty(&v2).unwrap())
.await
.unwrap();
Migrator::migrate(FormatVersion::V2, FormatVersion::V7, &path)
.await
.expect("V2→V7 must succeed");
let after: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v2.backup").exists(),
".v2.backup must be removed after successful V2→V7 migration"
);
}
#[tokio::test]
async fn test_migrate_v1_to_v7_cleans_up_v1_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v1.json");
let v1 = json!({
"boards": [],
"columns": [],
"cards": []
});
tokio::fs::write(&path, v1.to_string()).await.unwrap();
Migrator::migrate(FormatVersion::V1, FormatVersion::V7, &path)
.await
.expect("V1→V7 must succeed");
let after: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v1.backup").exists(),
".v1.backup must be removed after successful V1→V7 migration"
);
}
}