use kanban_persistence::{PersistenceError, PersistenceResult};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::Path;
pub(crate) async fn migrate_v2_to_v3(path: &Path) -> PersistenceResult<()> {
let content = tokio::fs::read_to_string(path).await?;
let mut envelope: Value = serde_json::from_str(&content)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
transform_v2_to_v3_value(&mut envelope)?;
let json_str = serde_json::to_string_pretty(&envelope)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
crate::atomic_writer::AtomicWriter::write_atomic(path, json_str.as_bytes()).await?;
tracing::info!("Migrated {} from V2 to V3 format", path.display());
Ok(())
}
pub(crate) fn transform_v2_to_v3_value(envelope: &mut Value) -> PersistenceResult<()> {
let data = envelope
.get_mut("data")
.ok_or_else(|| PersistenceError::Serialization("missing 'data' field".into()))?;
let mut column_to_board: HashMap<String, String> = HashMap::new();
if let Some(columns) = data.get("columns").and_then(|v| v.as_array()) {
for col in columns {
if let (Some(col_id), Some(board_id)) = (
col.get("id").and_then(|v| v.as_str()),
col.get("board_id").and_then(|v| v.as_str()),
) {
column_to_board.insert(col_id.to_string(), board_id.to_string());
}
}
}
let mut board_canonical_prefix: HashMap<String, String> = HashMap::new();
if let Some(boards) = data.get("boards").and_then(|v| v.as_array()) {
for board in boards {
if let Some(board_id) = board.get("id").and_then(|v| v.as_str()) {
let prefix = board
.get("card_prefix")
.and_then(|v| v.as_str())
.unwrap_or("task")
.to_string();
board_canonical_prefix.insert(board_id.to_string(), prefix);
}
}
}
let mut board_cards: HashMap<String, Vec<(String, u32, String)>> = HashMap::new();
for (source, is_archived) in &[("cards", false), ("archived_cards", true)] {
if let Some(cards) = data.get(*source).and_then(|v| v.as_array()) {
for card_val in cards {
let card = if *is_archived {
card_val.get("card").unwrap_or(card_val)
} else {
card_val
};
let col_id = card.get("column_id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(board_id) = column_to_board.get(col_id) {
let card_id = card
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let card_number = card
.get("card_number")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let prefix = card
.get("assigned_prefix")
.and_then(|v| v.as_str())
.unwrap_or("task")
.to_string();
board_cards.entry(board_id.clone()).or_default().push((
card_id,
card_number,
prefix,
));
}
}
}
}
let mut renumber_map: HashMap<String, u32> = HashMap::new();
let mut board_max_number: HashMap<String, u32> = HashMap::new();
for (board_id, cards) in &board_cards {
let canonical = board_canonical_prefix
.get(board_id)
.map(|s| s.as_str())
.unwrap_or("task");
let mut running_max: u32 = cards.iter().map(|(_, n, _)| *n).max().unwrap_or(0);
let mut number_to_entries: HashMap<u32, Vec<(&str, &str)>> = HashMap::new();
for (card_id, card_number, prefix) in cards {
number_to_entries
.entry(*card_number)
.or_default()
.push((card_id.as_str(), prefix.as_str()));
}
for entries in number_to_entries.values() {
let prefixes: HashSet<&str> = entries.iter().map(|(_, p)| *p).collect();
if prefixes.len() > 1 {
for (card_id, prefix) in entries {
if *prefix != canonical {
running_max += 1;
tracing::warn!(
"Migration: renumbering card {} (prefix='{}') to {} \
to resolve collision on board {}",
card_id,
prefix,
running_max,
board_id
);
renumber_map.insert(card_id.to_string(), running_max);
}
}
}
}
board_max_number.insert(board_id.clone(), running_max);
}
if let Some(cards) = data.get_mut("cards").and_then(|v| v.as_array_mut()) {
for card in cards.iter_mut() {
if let Some(obj) = card.as_object_mut() {
if let Some(card_id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
if let Some(&new_number) = renumber_map.get(&card_id) {
obj.insert("card_number".to_string(), Value::Number(new_number.into()));
}
}
}
}
}
if let Some(archived) = data
.get_mut("archived_cards")
.and_then(|v| v.as_array_mut())
{
for archived_card in archived.iter_mut() {
if let Some(card) = archived_card
.get_mut("card")
.and_then(|v| v.as_object_mut())
{
if let Some(card_id) = card.get("id").and_then(|v| v.as_str()).map(str::to_string) {
if let Some(&new_number) = renumber_map.get(&card_id) {
card.insert("card_number".to_string(), Value::Number(new_number.into()));
}
}
}
}
}
if let Some(boards) = data.get_mut("boards").and_then(|v| v.as_array_mut()) {
for board in boards.iter_mut() {
if let Some(board_obj) = board.as_object_mut() {
let board_id = board_obj
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let canonical = board_canonical_prefix
.get(&board_id)
.map(|s| s.as_str())
.unwrap_or("task");
let from_prefix_counters = board_obj
.get("prefix_counters")
.and_then(|v| v.as_object())
.and_then(|m| m.get(canonical).and_then(|v| v.as_u64()));
let from_max = board_max_number.get(&board_id).map(|&max| (max + 1) as u64);
let card_counter = match (from_prefix_counters, from_max) {
(Some(a), Some(b)) => a.max(b),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => 1,
};
board_obj.remove("prefix_counters");
board_obj.insert(
"card_counter".to_string(),
Value::Number(card_counter.into()),
);
}
}
}
strip_card_prefix_fields(data, "cards");
strip_card_prefix_fields_archived(data, "archived_cards");
envelope["version"] = Value::Number(3.into());
Ok(())
}
fn strip_card_prefix_fields(data: &mut Value, key: &str) {
if let Some(cards) = data.get_mut(key).and_then(|v| v.as_array_mut()) {
for card in cards.iter_mut() {
if let Some(obj) = card.as_object_mut() {
obj.remove("assigned_prefix");
obj.remove("card_prefix");
}
}
}
}
fn strip_card_prefix_fields_archived(data: &mut Value, key: &str) {
if let Some(archived) = data.get_mut(key).and_then(|v| v.as_array_mut()) {
for archived_card in archived.iter_mut() {
if let Some(card) = archived_card
.get_mut("card")
.and_then(|v| v.as_object_mut())
{
card.remove("assigned_prefix");
card.remove("card_prefix");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use kanban_persistence::FormatVersion;
use serde_json::json;
use tempfile::tempdir;
fn make_v2_envelope(data: Value) -> Value {
json!({
"version": 2,
"metadata": {
"instance_id": "00000000-0000-0000-0000-000000000001",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": data
})
}
#[tokio::test]
async fn test_migrate_v2_to_v3_converts_prefix_counters_to_card_counter() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.json");
let board_id = "board-1";
let col_id = "col-1";
let data = json!({
"boards": [{
"id": board_id,
"name": "Test",
"card_prefix": "TASK",
"prefix_counters": { "TASK": 5, "OTHER": 2 },
"sprint_counters": {}
}],
"columns": [{ "id": col_id, "board_id": board_id }],
"cards": [],
"archived_cards": [],
"sprints": []
});
let envelope = make_v2_envelope(data);
tokio::fs::write(&path, serde_json::to_string_pretty(&envelope).unwrap())
.await
.unwrap();
migrate_v2_to_v3(&path).await.unwrap();
let migrated: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(migrated["version"], 3);
let board = &migrated["data"]["boards"][0];
assert_eq!(board["card_counter"], 5);
assert!(board.get("prefix_counters").is_none());
}
#[tokio::test]
async fn test_migrate_v2_to_v3_drops_assigned_prefix_and_card_prefix_from_cards() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.json");
let board_id = "board-1";
let col_id = "col-1";
let data = json!({
"boards": [{
"id": board_id,
"name": "Test",
"card_prefix": null,
"prefix_counters": {},
"sprint_counters": {}
}],
"columns": [{ "id": col_id, "board_id": board_id }],
"cards": [{
"id": "card-1",
"column_id": col_id,
"card_number": 1,
"assigned_prefix": "task",
"card_prefix": null
}],
"archived_cards": [],
"sprints": []
});
let envelope = make_v2_envelope(data);
tokio::fs::write(&path, serde_json::to_string_pretty(&envelope).unwrap())
.await
.unwrap();
migrate_v2_to_v3(&path).await.unwrap();
let migrated: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
let card = &migrated["data"]["cards"][0];
assert!(card.get("assigned_prefix").is_none());
assert!(card.get("card_prefix").is_none());
}
#[tokio::test]
async fn test_migrate_v2_to_v3_drops_assigned_prefix_from_archived_card_cards() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.json");
let board_id = "board-1";
let col_id = "col-1";
let data = json!({
"boards": [{
"id": board_id,
"name": "Test",
"card_prefix": null,
"prefix_counters": {},
"sprint_counters": {}
}],
"columns": [{ "id": col_id, "board_id": board_id }],
"cards": [],
"archived_cards": [{
"card": {
"id": "card-2",
"column_id": col_id,
"card_number": 2,
"assigned_prefix": "task",
"card_prefix": null
},
"archived_at": "2024-01-01T00:00:00Z",
"original_column_id": col_id,
"original_position": 0
}],
"sprints": []
});
let envelope = make_v2_envelope(data);
tokio::fs::write(&path, serde_json::to_string_pretty(&envelope).unwrap())
.await
.unwrap();
migrate_v2_to_v3(&path).await.unwrap();
let migrated: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
let card = &migrated["data"]["archived_cards"][0]["card"];
assert!(card.get("assigned_prefix").is_none());
assert!(card.get("card_prefix").is_none());
}
#[tokio::test]
async fn test_migrate_v2_to_v3_collision_renumbers_non_canonical_cards() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.json");
let board_id = "board-1";
let col_id = "col-1";
let data = json!({
"boards": [{
"id": board_id,
"name": "Test",
"card_prefix": "TASK",
"prefix_counters": { "TASK": 2, "task": 2 },
"sprint_counters": {}
}],
"columns": [{ "id": col_id, "board_id": board_id }],
"cards": [
{
"id": "card-1",
"column_id": col_id,
"card_number": 1,
"assigned_prefix": "TASK"
},
{
"id": "card-2",
"column_id": col_id,
"card_number": 1,
"assigned_prefix": "task"
}
],
"archived_cards": [],
"sprints": []
});
let envelope = make_v2_envelope(data);
tokio::fs::write(&path, serde_json::to_string_pretty(&envelope).unwrap())
.await
.unwrap();
migrate_v2_to_v3(&path).await.unwrap();
let migrated: Value =
serde_json::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_eq!(migrated["version"], 3);
let cards = migrated["data"]["cards"].as_array().unwrap();
let card1 = cards.iter().find(|c| c["id"] == "card-1").unwrap();
let card2 = cards.iter().find(|c| c["id"] == "card-2").unwrap();
assert_eq!(card1["card_number"], 1, "canonical card keeps its number");
assert_eq!(
card2["card_number"], 2,
"non-canonical card is renumbered above max"
);
let board = &migrated["data"]["boards"][0];
assert_eq!(
board["card_counter"], 3,
"card_counter is renumbered_max + 1"
);
}
#[tokio::test]
async fn test_detect_v3_format_returns_v3() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.json");
let v3_data = json!({
"version": 3,
"metadata": {},
"data": {}
});
tokio::fs::write(&path, v3_data.to_string()).await.unwrap();
let version = crate::migration::Migrator::detect_version(&path)
.await
.unwrap();
assert_eq!(version, FormatVersion::V3);
}
}