use crate::atomic_writer::AtomicWriter;
use crate::conflict::FileMetadata;
use crate::migration::{
transform_to_v6_split_graph_value, transform_v2_to_v3_value, transform_v6_to_v7_value, Migrator,
};
use kanban_persistence::{
FormatVersion, PersistenceError, PersistenceMetadata, PersistenceResult, PersistenceStore,
StoreSnapshot,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use uuid::Uuid;
pub struct JsonFileStore {
path: PathBuf,
instance_id: Uuid,
last_known_metadata: Mutex<Option<FileMetadata>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JsonEnvelope {
version: u32,
metadata: PersistenceMetadata,
data: serde_json::Value,
}
const LEGACY_FIELDS: &[&str] = &[
"commands",
"undo_cursor",
"baseline_data",
"command_schema_version",
];
fn detect_legacy_fields(value: &serde_json::Value) -> Vec<&'static str> {
let Some(obj) = value.as_object() else {
return Vec::new();
};
LEGACY_FIELDS
.iter()
.copied()
.filter(|f| obj.contains_key(*f))
.collect()
}
impl JsonEnvelope {
pub fn new(data: serde_json::Value) -> Self {
Self {
version: 2,
metadata: PersistenceMetadata::new(Uuid::new_v4()),
data,
}
}
pub fn empty() -> Self {
Self::new(serde_json::json!({
"boards": [],
"columns": [],
"cards": [],
"archived_cards": [],
"sprints": []
}))
}
pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
fn migrate_to_v7_sync(from: FormatVersion, path: &Path) -> PersistenceResult<Vec<u8>> {
let backup_path = crate::migration::pre_v7_backup_path_for(from, path);
if let Some(backup) = &backup_path {
std::fs::copy(path, backup)?;
tracing::info!("Created pre-V7 backup at {}", backup.display());
}
let result = (|| -> PersistenceResult<Vec<u8>> {
if from == FormatVersion::V1 {
migrate_v1_to_v2_sync(path)?;
}
if from <= FormatVersion::V2 {
migrate_v2_to_v3_sync(path)?;
}
run_split_and_rename_chain_sync(from, path)
})();
match (result, backup_path) {
(Ok(bytes), Some(backup)) => {
if let Err(e) = std::fs::remove_file(&backup) {
tracing::warn!(
"Migration successful but failed to remove backup at {}: {}",
backup.display(),
e
);
} else {
tracing::info!("Migration to V7 verified, backup removed");
}
Ok(bytes)
}
(Ok(bytes), None) => Ok(bytes),
(Err(e), Some(backup)) => {
tracing::error!(
"Migration to V7 failed: {}. Backup preserved at {}",
e,
backup.display()
);
Err(e)
}
(Err(e), None) => Err(e),
}
}
fn run_split_and_rename_chain_sync(from: FormatVersion, path: &Path) -> PersistenceResult<Vec<u8>> {
if from < FormatVersion::V6 {
split_graph_sync(path)?;
}
v6_to_v7_rename_sync(path)
}
fn migrate_v1_to_v2_sync(path: &Path) -> PersistenceResult<Vec<u8>> {
let content = std::fs::read_to_string(path)?;
let v1_data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let v2_envelope = JsonEnvelope::new(v1_data);
let json_str = v2_envelope
.to_json_string()
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let json_bytes = json_str.into_bytes();
AtomicWriter::write_atomic_sync(path, &json_bytes)?;
tracing::info!("Migrated {} from V1 to V2 (sync)", path.display());
Ok(json_bytes)
}
fn migrate_v2_to_v3_sync(path: &Path) -> PersistenceResult<Vec<u8>> {
let content = std::fs::read_to_string(path)?;
let mut envelope: serde_json::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()))?;
let json_bytes = json_str.into_bytes();
AtomicWriter::write_atomic_sync(path, &json_bytes)?;
tracing::info!("Migrated {} from V2 to V3 (sync)", path.display());
Ok(json_bytes)
}
fn split_graph_sync(path: &Path) -> PersistenceResult<Vec<u8>> {
let content = std::fs::read_to_string(path)?;
let mut envelope: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
transform_to_v6_split_graph_value(&mut envelope)?;
let json_str = serde_json::to_string_pretty(&envelope)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let json_bytes = json_str.into_bytes();
AtomicWriter::write_atomic_sync(path, &json_bytes)?;
tracing::info!("Applied split-graph migration to {} (sync)", path.display());
Ok(json_bytes)
}
fn v6_to_v7_rename_sync(path: &Path) -> PersistenceResult<Vec<u8>> {
let content = std::fs::read_to_string(path)?;
let mut envelope: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
transform_v6_to_v7_value(&mut envelope)?;
let json_str = serde_json::to_string_pretty(&envelope)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let json_bytes = json_str.into_bytes();
AtomicWriter::write_atomic_sync(path, &json_bytes)?;
tracing::info!(
"Applied v6→v7 spawns-rename migration to {} (sync)",
path.display()
);
Ok(json_bytes)
}
impl JsonFileStore {
pub fn new(path: impl AsRef<Path>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
instance_id: Uuid::new_v4(),
last_known_metadata: Mutex::new(None),
}
}
pub fn with_instance_id(path: impl AsRef<Path>, instance_id: Uuid) -> Self {
Self {
path: path.as_ref().to_path_buf(),
instance_id,
last_known_metadata: Mutex::new(None),
}
}
pub fn instance_id(&self) -> Uuid {
self.instance_id
}
fn lock_metadata(&self) -> PersistenceResult<std::sync::MutexGuard<'_, Option<FileMetadata>>> {
self.last_known_metadata
.lock()
.map_err(|e| PersistenceError::Serialization(format!("Metadata mutex poisoned: {e}")))
}
fn parse_envelope(bytes: &[u8]) -> PersistenceResult<JsonEnvelope> {
serde_json::from_slice(bytes).map_err(|e| PersistenceError::Serialization(e.to_string()))
}
fn serialize_envelope(envelope: &JsonEnvelope) -> PersistenceResult<Vec<u8>> {
serde_json::to_vec_pretty(envelope)
.map_err(|e| PersistenceError::Serialization(e.to_string()))
}
async fn scrub_legacy_fields_async(
&self,
envelope: &JsonEnvelope,
detected: &[&'static str],
) -> PersistenceResult<()> {
tracing::info!(
"scrubbing pre-KAN-405 legacy fields {:?} from {}; undo history is now in-session only",
detected,
self.path.display()
);
let bytes = Self::serialize_envelope(envelope)?;
AtomicWriter::write_atomic(&self.path, &bytes).await?;
Ok(())
}
fn scrub_legacy_fields_sync(
&self,
envelope: &JsonEnvelope,
detected: &[&'static str],
) -> PersistenceResult<()> {
tracing::info!(
"scrubbing pre-KAN-405 legacy fields {:?} from {} (sync); undo history is now in-session only",
detected,
self.path.display()
);
let bytes = Self::serialize_envelope(envelope)?;
AtomicWriter::write_atomic_sync(&self.path, &bytes)?;
Ok(())
}
}
#[async_trait::async_trait]
impl PersistenceStore for JsonFileStore {
async fn save(&self, mut snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {
if self.path.exists() {
let current_metadata =
FileMetadata::from_file(&self.path).map_err(PersistenceError::Io)?;
let guard = self.lock_metadata()?;
if let Some(last_known) = *guard {
if last_known != current_metadata {
return Err(PersistenceError::ConflictDetected {
path: self.path.to_string_lossy().to_string(),
source: None,
});
}
}
}
snapshot.metadata.instance_id = self.instance_id;
snapshot.metadata.saved_at = chrono::Utc::now();
snapshot.metadata.writer_version = Some(kanban_core::KANBAN_VERSION.to_string());
snapshot.metadata.writer_commit = Some(kanban_core::KANBAN_COMMIT.to_string());
let data_value: serde_json::Value = serde_json::from_slice(&snapshot.data)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let envelope = JsonEnvelope {
version: 7,
metadata: snapshot.metadata.clone(),
data: data_value,
};
let json_bytes = serde_json::to_vec_pretty(&envelope)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
AtomicWriter::write_atomic(&self.path, &json_bytes).await?;
if let Ok(new_metadata) = FileMetadata::from_file(&self.path) {
let mut guard = self.lock_metadata()?;
*guard = Some(new_metadata);
}
tracing::info!(
"Saved {} bytes to {}",
json_bytes.len(),
self.path.display()
);
Ok(snapshot.metadata)
}
async fn load(&self) -> PersistenceResult<(StoreSnapshot, PersistenceMetadata)> {
let current_version = Migrator::detect_version(&self.path).await?;
if current_version < FormatVersion::V7 {
tracing::info!(
"Detected {:?} format at {}. Migrating to V7...",
current_version,
self.path.display()
);
Migrator::migrate(current_version, FormatVersion::V7, &self.path).await?;
tracing::info!("Migration to V7 completed successfully");
}
let file_bytes = tokio::fs::read(&self.path).await?;
let envelope = Self::parse_envelope(&file_bytes)?;
let raw_value: serde_json::Value = serde_json::from_slice(&file_bytes)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let detected = detect_legacy_fields(&raw_value);
if !detected.is_empty() {
if let Err(e) = self.scrub_legacy_fields_async(&envelope, &detected).await {
tracing::warn!(
"failed to scrub legacy fields from {}: {}; data still loaded successfully, cleanup will be retried on next open",
self.path.display(),
e
);
}
}
let data = serde_json::to_vec(&envelope.data)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let mut metadata = envelope.metadata;
metadata.format_version = Some(envelope.version);
let snapshot = StoreSnapshot {
data,
metadata: metadata.clone(),
};
if let Ok(file_metadata) = FileMetadata::from_file(&self.path) {
let mut guard = self.lock_metadata()?;
*guard = Some(file_metadata);
}
tracing::info!(
"Loaded {} bytes from {}",
file_bytes.len(),
self.path.display()
);
Ok((snapshot, metadata))
}
fn load_sync(&self) -> PersistenceResult<Option<(StoreSnapshot, PersistenceMetadata)>> {
if !self.path.exists() {
return Ok(None);
}
let file_bytes = std::fs::read(&self.path)?;
let value: serde_json::Value = serde_json::from_slice(&file_bytes)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let current_version = Migrator::detect_version_from_value(&value)?;
let final_bytes = if current_version < FormatVersion::V7 {
tracing::info!(
"Detected {:?} format at {}. Migrating to V7 (sync)...",
current_version,
self.path.display()
);
migrate_to_v7_sync(current_version, &self.path)?
} else {
file_bytes
};
let envelope = Self::parse_envelope(&final_bytes)?;
let raw_value: serde_json::Value = serde_json::from_slice(&final_bytes)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let detected = detect_legacy_fields(&raw_value);
if !detected.is_empty() {
if let Err(e) = self.scrub_legacy_fields_sync(&envelope, &detected) {
tracing::warn!(
"failed to scrub legacy fields from {} (sync): {}; data still loaded successfully, cleanup will be retried on next open",
self.path.display(),
e
);
}
}
let data = serde_json::to_vec(&envelope.data)
.map_err(|e| PersistenceError::Serialization(e.to_string()))?;
let mut metadata = envelope.metadata;
metadata.format_version = Some(envelope.version);
let snapshot = StoreSnapshot {
data,
metadata: metadata.clone(),
};
if let Ok(file_metadata) = FileMetadata::from_file(&self.path) {
let mut guard = self.lock_metadata()?;
*guard = Some(file_metadata);
}
tracing::info!(
"Loaded {} bytes from {} (sync)",
final_bytes.len(),
self.path.display()
);
Ok(Some((snapshot, metadata)))
}
async fn exists(&self) -> bool {
self.path.exists()
}
fn path(&self) -> &Path {
&self.path
}
fn instance_id(&self) -> Uuid {
self.instance_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_save_and_load() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.json");
let store = JsonFileStore::new(&file_path);
let data = json!({ "boards": [], "columns": [] });
let snapshot = StoreSnapshot {
data: serde_json::to_vec(&data).unwrap(),
metadata: PersistenceMetadata::new(store.instance_id()),
};
let _metadata = store.save(snapshot.clone()).await.unwrap();
assert!(file_path.exists());
let (loaded_snapshot, _loaded_metadata) = store.load().await.unwrap();
let loaded_data: serde_json::Value = serde_json::from_slice(&loaded_snapshot.data).unwrap();
assert_eq!(loaded_data, data);
}
#[tokio::test]
async fn test_exists() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("nonexistent.json");
let store = JsonFileStore::new(&file_path);
assert!(!store.exists().await);
let data = json!({});
let snapshot = StoreSnapshot {
data: serde_json::to_vec(&data).unwrap(),
metadata: PersistenceMetadata::new(store.instance_id()),
};
store.save(snapshot).await.unwrap();
assert!(store.exists().await);
}
#[test]
fn test_json_envelope_empty_structure() {
let envelope = JsonEnvelope::empty();
let json = serde_json::to_value(envelope).unwrap();
assert_eq!(json["version"], 2);
assert!(json["metadata"].is_object());
assert!(json["data"]["boards"].is_array());
assert!(json["data"]["columns"].is_array());
assert!(json["data"]["cards"].is_array());
assert!(json["data"]["archived_cards"].is_array());
assert!(json["data"]["sprints"].is_array());
}
#[test]
fn test_lock_metadata_returns_result_not_panic() {
let store = JsonFileStore::new("/tmp/nonexistent.json");
let guard = store.lock_metadata();
assert!(guard.is_ok());
assert!(guard.unwrap().is_none());
}
#[tokio::test]
async fn test_load_rejects_future_format_version() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("future.json");
let v99 = json!({
"version": 99,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2026-05-23T00:00:00Z"
},
"data": {}
});
tokio::fs::write(&file_path, v99.to_string()).await.unwrap();
let store = JsonFileStore::new(&file_path);
let err = store.load().await.expect_err("v99 must refuse to load");
assert!(
matches!(
err,
PersistenceError::UnsupportedFutureVersion {
file_version: 99,
binary_max: 7
}
),
"expected UnsupportedFutureVersion, got: {err:?}"
);
}
#[test]
fn test_load_sync_rejects_future_format_version() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("future.json");
let v99 = json!({
"version": 99,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2026-05-23T00:00:00Z"
},
"data": {}
});
std::fs::write(&file_path, v99.to_string()).unwrap();
let store = JsonFileStore::new(&file_path);
let err = store
.load_sync()
.expect_err("v99 must refuse to load (sync)");
assert!(
matches!(
err,
PersistenceError::UnsupportedFutureVersion {
file_version: 99,
binary_max: 7
}
),
"expected UnsupportedFutureVersion, got: {err:?}"
);
}
#[tokio::test]
async fn test_save_stamps_writer_version_and_commit() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("stamped.json");
let store = JsonFileStore::new(&file_path);
let snapshot = StoreSnapshot {
data: serde_json::to_vec(&json!({ "boards": [], "columns": [] })).unwrap(),
metadata: PersistenceMetadata::new(store.instance_id()),
};
store.save(snapshot).await.unwrap();
let bytes = tokio::fs::read(&file_path).await.unwrap();
let envelope: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
envelope["metadata"]["writer_version"]
.as_str()
.map(str::to_string),
Some(kanban_core::KANBAN_VERSION.to_string()),
);
assert_eq!(
envelope["metadata"]["writer_commit"]
.as_str()
.map(str::to_string),
Some(kanban_core::KANBAN_COMMIT.to_string()),
);
}
#[tokio::test]
async fn test_load_legacy_file_without_writer_stamp_succeeds() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("legacy_no_stamp.json");
let legacy = 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": [] },
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
tokio::fs::write(&file_path, legacy.to_string())
.await
.unwrap();
let store = JsonFileStore::new(&file_path);
let (_, metadata) = store.load().await.unwrap();
assert!(metadata.writer_version.is_none());
assert!(metadata.writer_commit.is_none());
}
#[tokio::test]
async fn test_load_v6_file_with_parent_child_migrates_to_v7_spawns() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("v6_parent_child.json");
let parent = "550e8400-e29b-41d4-a716-446655440011";
let child = "550e8400-e29b-41d4-a716-446655440012";
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": parent,
"target": child,
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
}]},
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
tokio::fs::write(&file_path, v6.to_string()).await.unwrap();
let store = JsonFileStore::new(&file_path);
let _ = store.load().await.unwrap();
let after = tokio::fs::read_to_string(&file_path).await.unwrap();
let v: serde_json::Value = serde_json::from_str(&after).unwrap();
assert_eq!(v["version"], 7, "load must migrate V6 to V7 on disk");
let graph = v["data"]["graph"].as_object().expect("graph object");
assert!(
graph.contains_key("spawns"),
"V7 graph bucket key must be `spawns`; got {:?}",
graph.keys().collect::<Vec<_>>()
);
assert!(
!graph.contains_key("parent_child"),
"legacy `parent_child` key must be gone after V7 migration"
);
let edges = graph["spawns"]["edges"]
.as_array()
.expect("spawns edges array");
assert_eq!(
edges.len(),
1,
"the original parent_child edge must survive"
);
assert_eq!(edges[0]["source"], parent);
assert_eq!(edges[0]["target"], child);
}
#[test]
fn test_load_sync_v6_file_with_parent_child_migrates_to_v7_spawns() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("v6_sync.json");
let parent = "550e8400-e29b-41d4-a716-446655440021";
let child = "550e8400-e29b-41d4-a716-446655440022";
let v6 = json!({
"version": 6,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440020",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [], "columns": [], "cards": [], "archived_cards": [], "sprints": [],
"graph": {
"parent_child": { "edges": [{
"source": parent,
"target": child,
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
}]},
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
std::fs::write(&file_path, v6.to_string()).unwrap();
let store = JsonFileStore::new(&file_path);
let _ = store.load_sync().unwrap().expect("file exists");
let after = std::fs::read_to_string(&file_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&after).unwrap();
assert_eq!(v["version"], 7, "load_sync must migrate V6 to V7 on disk");
let graph = v["data"]["graph"].as_object().expect("graph object");
assert!(
graph.contains_key("spawns"),
"V7 graph bucket key must be `spawns`; got {:?}",
graph.keys().collect::<Vec<_>>()
);
assert!(
!graph.contains_key("parent_child"),
"legacy `parent_child` key must be gone after V7 migration"
);
let edges = graph["spawns"]["edges"]
.as_array()
.expect("spawns edges array");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0]["source"], parent);
assert_eq!(edges[0]["target"], child);
}
#[tokio::test]
async fn test_legacy_command_fields_are_scrubbed_from_disk_on_load() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("legacy.json");
let legacy = json!({
"version": 5,
"metadata": {
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
},
"data": {
"boards": [{"id": "550e8400-e29b-41d4-a716-446655440001", "name": "B",
"task_sort_field": "Default", "task_sort_order": "Ascending",
"sprint_name_used_count": 0, "next_sprint_number": 1,
"task_list_view": "Flat", "prefix_counters": {}, "sprint_counters": {},
"card_counter": 0, "position": 0,
"created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}],
"columns": [], "cards": [], "archived_cards": [], "sprints": [],
"graph": { "cards": { "edges": [] } }
},
"commands": [{"type": "Board", "variant": "Create", "id": "00000000-0000-0000-0000-000000000001"}],
"undo_cursor": 1,
"command_schema_version": 1,
"baseline_data": {}
});
tokio::fs::write(&file_path, legacy.to_string())
.await
.unwrap();
let store = JsonFileStore::new(&file_path);
let (snapshot, _meta) = store.load().await.unwrap();
let loaded: serde_json::Value = serde_json::from_slice(&snapshot.data).unwrap();
assert_eq!(loaded["boards"][0]["name"], "B", "board data must survive");
let on_disk_bytes = tokio::fs::read(&file_path).await.unwrap();
let on_disk: serde_json::Value = serde_json::from_slice(&on_disk_bytes).unwrap();
let keys: Vec<_> = on_disk.as_object().unwrap().keys().cloned().collect();
assert!(
!keys.iter().any(|k| k == "commands"),
"commands field must be scrubbed from disk, found keys: {keys:?}"
);
assert!(
!keys.iter().any(|k| k == "undo_cursor"),
"undo_cursor field must be scrubbed from disk, found keys: {keys:?}"
);
assert!(
!keys.iter().any(|k| k == "baseline_data"),
"baseline_data field must be scrubbed from disk, found keys: {keys:?}"
);
assert!(
!keys.iter().any(|k| k == "command_schema_version"),
"command_schema_version field must be scrubbed from disk, found keys: {keys:?}"
);
assert_eq!(
on_disk["data"]["boards"][0]["name"], "B",
"board data must remain on disk after scrub"
);
}
#[test]
fn test_legacy_command_fields_are_scrubbed_from_disk_on_load_sync() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("legacy_sync.json");
let legacy = 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": [] } }
},
"commands": [],
"undo_cursor": 0,
"command_schema_version": 1,
"baseline_data": {}
});
std::fs::write(&file_path, legacy.to_string()).unwrap();
let store = JsonFileStore::new(&file_path);
let _ = store.load_sync().unwrap().expect("file exists");
let on_disk_bytes = std::fs::read(&file_path).unwrap();
let on_disk: serde_json::Value = serde_json::from_slice(&on_disk_bytes).unwrap();
let keys: Vec<_> = on_disk.as_object().unwrap().keys().cloned().collect();
for legacy_key in [
"commands",
"undo_cursor",
"baseline_data",
"command_schema_version",
] {
assert!(
!keys.iter().any(|k| k == legacy_key),
"{legacy_key} must be scrubbed from disk by load_sync, found keys: {keys:?}"
);
}
}
#[tokio::test]
async fn test_load_is_a_noop_write_when_no_legacy_fields_present() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("clean.json");
let clean = 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_bytes = serde_json::to_vec_pretty(&clean).unwrap();
tokio::fs::write(&file_path, &original_bytes).await.unwrap();
let store = JsonFileStore::new(&file_path);
let _ = store.load().await.unwrap();
let after_bytes = tokio::fs::read(&file_path).await.unwrap();
assert_eq!(
original_bytes, after_bytes,
"loading a clean file must not rewrite it"
);
}
#[tokio::test]
async fn test_v3_file_with_edges_round_trips_through_migration_and_load() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("v3_with_edges.json");
let v3_content = json!({
"version": 3,
"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": [
{
"source": "11111111-1111-1111-1111-111111111111",
"target": "22222222-2222-2222-2222-222222222222",
"edge_type": "ParentOf",
"direction": "Directed",
"weight": null,
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
},
{
"source": "33333333-3333-3333-3333-333333333333",
"target": "44444444-4444-4444-4444-444444444444",
"edge_type": "Blocks",
"direction": "Directed",
"weight": null,
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
},
{
"source": "55555555-5555-5555-5555-555555555555",
"target": "66666666-6666-6666-6666-666666666666",
"edge_type": "RelatesTo",
"direction": "Bidirectional",
"weight": null,
"created_at": "2024-01-01T00:00:00Z",
"archived_at": null
}
]
}
}
}
});
tokio::fs::write(&file_path, v3_content.to_string())
.await
.unwrap();
let store = JsonFileStore::new(&file_path);
store
.load()
.await
.expect("first load (migration) must succeed");
let store2 = JsonFileStore::new(&file_path);
let (snapshot, _meta) = store2
.load()
.await
.expect("re-load of migrated file must succeed");
use kanban_persistence::snapshot_from_json_bytes;
let domain_snapshot = snapshot_from_json_bytes(&snapshot.data)
.expect("snapshot must deserialize through the full domain stack after migration");
assert_eq!(domain_snapshot.graph.spawns_edges().len(), 1);
assert_eq!(domain_snapshot.graph.blocks_edges().len(), 1);
assert_eq!(domain_snapshot.graph.relates_edges().len(), 1);
}
#[tokio::test]
async fn test_v3_file_loads_correctly() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("v3.json");
let v3_content = json!({
"version": 3,
"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(&file_path, v3_content.to_string())
.await
.unwrap();
let store = JsonFileStore::new(&file_path);
let (snapshot, _meta) = store.load().await.unwrap();
let loaded: serde_json::Value = serde_json::from_slice(&snapshot.data).unwrap();
assert!(loaded["boards"].is_array());
}
#[test]
fn test_migrate_v1_to_v2_sync_produces_valid_v2_and_leaves_no_artifacts() {
let dir = tempdir().unwrap();
let path = dir.path().join("data.json");
let v1_content = json!({ "boards": [] });
std::fs::write(&path, v1_content.to_string()).unwrap();
let store = JsonFileStore::new(&path);
let result = store.load_sync().unwrap();
assert!(result.is_some(), "load_sync must return a snapshot");
let on_disk: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let version = on_disk.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
assert!(
version >= 2,
"file on disk must be V2+ envelope after migration"
);
let backup_path = path.with_extension("v1.backup");
assert!(
!backup_path.exists(),
".v1.backup must not remain after successful migration"
);
let tmp_path = path.with_extension("tmp");
assert!(
!tmp_path.exists(),
".tmp must not remain after successful migration"
);
}
#[test]
fn test_load_sync_v6_to_v7_preserves_v6_backup_on_failure() {
let dir = tempdir().unwrap();
let path = dir.path().join("v6_ambiguous_sync.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": [] }
}
}
});
std::fs::write(&path, serde_json::to_string_pretty(&v6).unwrap()).unwrap();
let store = JsonFileStore::new(&path);
let err = store
.load_sync()
.expect_err("load_sync 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 sync step fails so the user can recover"
);
}
#[test]
fn test_load_sync_v6_to_v7_cleans_up_v6_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v6_clean_sync.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": [] },
"blocks": { "edges": [] },
"relates": { "edges": [] }
}
}
});
std::fs::write(&path, serde_json::to_string_pretty(&v6).unwrap()).unwrap();
let store = JsonFileStore::new(&path);
let _ = store.load_sync().unwrap().expect("file exists");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v6.backup").exists(),
".v6.backup must be removed after successful V6→V7 sync migration"
);
}
#[test]
fn test_load_sync_v5_to_v7_cleans_up_v5_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v5_clean_sync.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": [] } }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&v5).unwrap()).unwrap();
let store = JsonFileStore::new(&path);
let _ = store.load_sync().unwrap().expect("file exists");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v5.backup").exists(),
".v5.backup must be removed after successful V5→V7 sync migration"
);
}
#[test]
fn test_load_sync_v4_to_v7_cleans_up_v4_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v4_clean_sync.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": [] } }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&v4).unwrap()).unwrap();
let store = JsonFileStore::new(&path);
let _ = store.load_sync().unwrap().expect("file exists");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v4.backup").exists(),
".v4.backup must be removed after successful V4→V7 sync migration"
);
}
#[test]
fn test_load_sync_v3_to_v7_cleans_up_v3_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v3_clean_sync.json");
let v3 = json!({
"version": 3,
"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": [] } }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&v3).unwrap()).unwrap();
let store = JsonFileStore::new(&path);
let _ = store.load_sync().unwrap().expect("file exists");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v3.backup").exists(),
".v3.backup must be removed after successful V3→V7 sync migration"
);
}
#[test]
fn test_load_sync_v2_to_v7_cleans_up_v2_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v2_clean_sync.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": []
}
});
std::fs::write(&path, serde_json::to_string_pretty(&v2).unwrap()).unwrap();
let store = JsonFileStore::new(&path);
let _ = store.load_sync().unwrap().expect("file exists");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v2.backup").exists(),
".v2.backup must be removed after successful V2→V7 sync migration"
);
}
#[test]
fn test_load_sync_v1_to_v7_cleans_up_v1_backup_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("v1_clean_sync.json");
let v1 = json!({
"boards": [],
"columns": [],
"cards": []
});
std::fs::write(&path, v1.to_string()).unwrap();
let store = JsonFileStore::new(&path);
let _ = store.load_sync().unwrap().expect("file exists");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after["version"], 7);
assert!(
!path.with_extension("v1.backup").exists(),
".v1.backup must be removed after successful V1→V7 sync migration"
);
}
}