add utils.rs and state.rs
This commit is contained in:
247
src-tauri/src/state.rs
Normal file
247
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
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<Value> {
|
||||
let path_str = path.as_os_str().to_string_lossy();
|
||||
|
||||
// Build candidate list: primary, lastgood, bak1..bak{backup_count+2}
|
||||
let mut candidates: Vec<std::path::PathBuf> = 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::<Value>(&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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user