//! 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(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 Deserialize<'de>>(path: &Path) -> std::io::Result { 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 Deserialize<'de> + Default>( path: &Path, ) -> std::io::Result { 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 = 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 = 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()); } }