sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user