add utils.rs and state.rs

This commit is contained in:
2026-02-19 01:47:37 +02:00
parent 0b7532acd3
commit 5dd817cf37
3 changed files with 947 additions and 0 deletions

247
src-tauri/src/state.rs Normal file
View 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());
}
}