119 lines
3.4 KiB
Rust
119 lines
3.4 KiB
Rust
//! JSON read/write helpers with atomic writes.
|
|
//!
|
|
//! Follows the archivist pattern: write to .tmp, then rename.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
/// Write a value to a JSON file atomically.
|
|
///
|
|
/// 1. Serializes to pretty-printed JSON
|
|
/// 2. Writes to `{path}.tmp`
|
|
/// 3. Renames temp file to target (atomic on most filesystems)
|
|
pub async fn write_json<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
|
|
let json = serde_json::to_string_pretty(value)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
|
|
let temp_path = path.with_extension("tmp");
|
|
|
|
let mut file = tokio::fs::File::create(&temp_path).await?;
|
|
file.write_all(json.as_bytes()).await?;
|
|
file.sync_all().await?;
|
|
drop(file);
|
|
|
|
tokio::fs::rename(&temp_path, path).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Read a value from a JSON file.
|
|
///
|
|
/// Returns `NotFound` if the file doesn't exist.
|
|
pub async fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> std::io::Result<T> {
|
|
let content = tokio::fs::read_to_string(path).await?;
|
|
let value: T = serde_json::from_str(&content)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
Ok(value)
|
|
}
|
|
|
|
/// Read a value from a JSON file, returning a default if the file doesn't exist.
|
|
pub async fn read_json_or_default<T: for<'de> Deserialize<'de> + Default>(
|
|
path: &Path,
|
|
) -> std::io::Result<T> {
|
|
match read_json(path).await {
|
|
Ok(value) => Ok(value),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
struct TestData {
|
|
id: String,
|
|
value: i32,
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_and_read_roundtrip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("test.json");
|
|
|
|
let data = TestData {
|
|
id: "test".to_string(),
|
|
value: 42,
|
|
};
|
|
|
|
write_json(&path, &data).await.unwrap();
|
|
let read: TestData = read_json(&path).await.unwrap();
|
|
assert_eq!(read, data);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_read_missing_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("missing.json");
|
|
|
|
let result: std::io::Result<TestData> = read_json(&path).await;
|
|
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_read_json_or_default() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("missing.json");
|
|
|
|
let result: Vec<String> = read_json_or_default(&path).await.unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_atomic_overwrite() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("test.json");
|
|
|
|
let data1 = TestData {
|
|
id: "first".to_string(),
|
|
value: 1,
|
|
};
|
|
let data2 = TestData {
|
|
id: "second".to_string(),
|
|
value: 2,
|
|
};
|
|
|
|
write_json(&path, &data1).await.unwrap();
|
|
write_json(&path, &data2).await.unwrap();
|
|
|
|
let read: TestData = read_json(&path).await.unwrap();
|
|
assert_eq!(read, data2);
|
|
|
|
// Temp file should not remain
|
|
assert!(!path.with_extension("tmp").exists());
|
|
}
|
|
}
|