use serde_json::Value; use std::fs; use std::path::Path; /// Default number of rolling backups to keep. pub const BACKUP_COUNT: usize = 8; /// Write JSON data to a file atomically with backup rotation. /// /// Creates rolling backups (.bak1 through .bakN) and a .lastgood copy /// for crash recovery. /// /// For a file `foo.json`, backups are `foo.json.bak1`, `foo.json.tmp`, etc. pub fn atomic_write_json(path: &Path, data: &Value, backup_count: usize) { // Create parent directories if needed if let Some(parent) = path.parent() { fs::create_dir_all(parent).ok(); } let path_str = path.as_os_str().to_string_lossy(); let tmp = Path::new(&*format!("{}.tmp", path_str)).to_path_buf(); let payload = serde_json::to_string_pretty(data).expect("failed to serialize JSON"); if path.exists() { // Rotate existing backups: move .bakN -> .bak(N+1), down to .bak1 -> .bak2 for i in (1..=backup_count).rev() { let src = Path::new(&*format!("{}.bak{}", path_str, i)).to_path_buf(); let dst = Path::new(&*format!("{}.bak{}", path_str, i + 1)).to_path_buf(); if src.exists() { // Remove dst if it exists, then rename src -> dst fs::remove_file(&dst).ok(); fs::rename(&src, &dst).ok(); } } // Move current file to .bak1 let bak1 = Path::new(&*format!("{}.bak1", path_str)).to_path_buf(); fs::remove_file(&bak1).ok(); fs::rename(path, &bak1).ok(); } // Write atomically via tmp file fs::write(&tmp, &payload).expect("failed to write tmp file"); fs::rename(&tmp, path).expect("failed to rename tmp to primary"); // Keep a .lastgood copy for recovery let lastgood = Path::new(&*format!("{}.lastgood", path_str)).to_path_buf(); fs::write(&lastgood, &payload).ok(); } /// Load JSON from path, falling back to backups if the primary is corrupted. /// /// Tries: path -> .lastgood -> .bak1 -> .bak2 -> ... -> .bak{backup_count+2} /// Returns `None` if all candidates fail. pub fn load_json_with_fallbacks(path: &Path, backup_count: usize) -> Option { let path_str = path.as_os_str().to_string_lossy(); // Build candidate list: primary, lastgood, bak1..bak{backup_count+2} let mut candidates: Vec = Vec::new(); candidates.push(path.to_path_buf()); candidates.push(Path::new(&*format!("{}.lastgood", path_str)).to_path_buf()); for i in 1..=(backup_count + 2) { candidates.push(Path::new(&*format!("{}.bak{}", path_str, i)).to_path_buf()); } for p in &candidates { if p.exists() { if let Ok(text) = fs::read_to_string(p) { if let Ok(val) = serde_json::from_str::(&text) { return Some(val); } } } } None } #[cfg(test)] mod tests { use super::*; use serde_json::json; use tempfile::TempDir; /// Helper: path to a JSON file inside a temp dir. fn json_path(dir: &TempDir) -> std::path::PathBuf { dir.path().join("data.json") } #[test] fn test_write_and_read_round_trip() { let dir = TempDir::new().unwrap(); let path = json_path(&dir); let data = json!({"key": "value", "num": 42}); atomic_write_json(&path, &data, BACKUP_COUNT); let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); assert_eq!(loaded, Some(data)); } #[test] fn test_fallback_to_lastgood_when_primary_corrupted() { let dir = TempDir::new().unwrap(); let path = json_path(&dir); let data = json!({"status": "good"}); // Write valid data (creates primary + lastgood) atomic_write_json(&path, &data, BACKUP_COUNT); // Corrupt the primary file fs::write(&path, "NOT VALID JSON!!!").unwrap(); let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); assert_eq!(loaded, Some(data)); } #[test] fn test_fallback_to_bak1_when_primary_and_lastgood_corrupted() { let dir = TempDir::new().unwrap(); let path = json_path(&dir); let path_str = path.as_os_str().to_string_lossy().to_string(); let first = json!({"version": 1}); let second = json!({"version": 2}); // First write - creates primary + lastgood atomic_write_json(&path, &first, BACKUP_COUNT); // Second write - rotates first to .bak1, writes second as primary + lastgood atomic_write_json(&path, &second, BACKUP_COUNT); // Corrupt primary and lastgood fs::write(&path, "CORRUPT").unwrap(); let lastgood = format!("{}.lastgood", path_str); fs::write(&lastgood, "ALSO CORRUPT").unwrap(); // Should fall back to .bak1 which has first version let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); assert_eq!(loaded, Some(first)); } #[test] fn test_backup_rotation_after_multiple_writes() { let dir = TempDir::new().unwrap(); let path = json_path(&dir); let path_str = path.as_os_str().to_string_lossy().to_string(); // Write 5 times with distinct values for i in 1..=5 { let data = json!({"write": i}); atomic_write_json(&path, &data, BACKUP_COUNT); } // Primary should be the latest (write 5) let primary: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(primary, json!({"write": 5})); // .bak1 should be the second-to-last (write 4) let bak1_path = format!("{}.bak1", path_str); let bak1: Value = serde_json::from_str(&fs::read_to_string(&bak1_path).unwrap()).unwrap(); assert_eq!(bak1, json!({"write": 4})); // .bak2 should be write 3 let bak2_path = format!("{}.bak2", path_str); let bak2: Value = serde_json::from_str(&fs::read_to_string(&bak2_path).unwrap()).unwrap(); assert_eq!(bak2, json!({"write": 3})); // .bak3 should be write 2 let bak3_path = format!("{}.bak3", path_str); let bak3: Value = serde_json::from_str(&fs::read_to_string(&bak3_path).unwrap()).unwrap(); assert_eq!(bak3, json!({"write": 2})); // .bak4 should be write 1 let bak4_path = format!("{}.bak4", path_str); let bak4: Value = serde_json::from_str(&fs::read_to_string(&bak4_path).unwrap()).unwrap(); assert_eq!(bak4, json!({"write": 1})); } #[test] fn test_load_nonexistent_returns_none() { let dir = TempDir::new().unwrap(); let path = dir.path().join("does_not_exist.json"); let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); assert_eq!(loaded, None); } #[test] fn test_parent_directories_created() { let dir = TempDir::new().unwrap(); let path = dir.path().join("sub").join("dir").join("nested.json"); let data = json!({"nested": true}); atomic_write_json(&path, &data, BACKUP_COUNT); let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); assert_eq!(loaded, Some(data)); } #[test] fn test_lastgood_written() { let dir = TempDir::new().unwrap(); let path = json_path(&dir); let path_str = path.as_os_str().to_string_lossy().to_string(); let data = json!({"lg": true}); atomic_write_json(&path, &data, BACKUP_COUNT); let lastgood_path = format!("{}.lastgood", path_str); let lg: Value = serde_json::from_str(&fs::read_to_string(&lastgood_path).unwrap()).unwrap(); assert_eq!(lg, data); } #[test] fn test_backup_count_respected() { let dir = TempDir::new().unwrap(); let path = json_path(&dir); let path_str = path.as_os_str().to_string_lossy().to_string(); let small_count = 2; // Write 5 times with a backup_count of 2 for i in 1..=5 { let data = json!({"write": i}); atomic_write_json(&path, &data, small_count); } // With backup_count=2, rotation only goes up to .bak2 -> .bak3 // After 5 writes: primary=5, bak1=4, bak2=3, bak3=2 (pushed from bak2) // .bak1 should exist let bak1_path = format!("{}.bak1", path_str); assert!(Path::new(&bak1_path).exists()); // .bak2 should exist let bak2_path = format!("{}.bak2", path_str); assert!(Path::new(&bak2_path).exists()); // .bak3 should exist (rotated from bak2) let bak3_path = format!("{}.bak3", path_str); assert!(Path::new(&bak3_path).exists()); } }