use anyhow::{Context, Result};
use serde::{Serialize, de::DeserializeOwned};
use std::path::{Path, PathBuf};
use crate::gateway::filesystem::Filesystem;
pub fn load_json<T>(path: &Path, fs: &dyn Filesystem) -> Result<T>
where
T: DeserializeOwned + Default,
{
if !fs.exists(path) {
return Ok(T::default());
}
let content = fs.read_to_string(path)?;
let value: T = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(value)
}
pub fn tmp_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
pub fn save_json<T>(value: &T, path: &Path, fs: &dyn Filesystem) -> Result<()>
where
T: Serialize,
{
if let Some(parent) = path.parent() {
fs.create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(value)?;
let tmp = tmp_path(path);
fs.write(&tmp, &content)?;
fs.rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gateway::fakes::FakeFilesystem;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
struct TestData {
value: String,
count: u32,
}
#[test]
fn load_json_returns_default_when_file_absent() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/nonexistent/data.json");
let result: TestData = load_json(&path, &fs).unwrap();
assert_eq!(result, TestData::default());
}
#[test]
fn load_json_round_trip_save_then_load() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/data.json");
let data = TestData {
value: "hello".to_string(),
count: 42,
};
save_json(&data, &path, &fs).unwrap();
let loaded: TestData = load_json(&path, &fs).unwrap();
assert_eq!(loaded, data);
}
#[test]
fn load_json_returns_error_on_malformed_json() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/data.json");
fs.add_file(path.clone(), "not json {{{{");
let result: Result<TestData> = load_json(&path, &fs);
assert!(result.is_err());
}
#[test]
fn tmp_path_appends_tmp_suffix() {
let path = PathBuf::from("/config/data.json");
assert_eq!(tmp_path(&path), PathBuf::from("/config/data.json.tmp"));
}
#[test]
fn save_json_failed_write_leaves_existing_file_intact() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/data.json");
let original = TestData {
value: "original".to_string(),
count: 1,
};
save_json(&original, &path, &fs).unwrap();
fs.set_fail_on_write(tmp_path(&path));
let new_data = TestData {
value: "new".to_string(),
count: 2,
};
let result = save_json(&new_data, &path, &fs);
assert!(result.is_err(), "expected Err when temp write fails");
let loaded: TestData = load_json(&path, &fs).unwrap();
assert_eq!(loaded, original, "existing file should be intact after failed temp write");
}
#[test]
fn save_json_failed_rename_leaves_existing_file_intact() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/data.json");
let original = TestData {
value: "original".to_string(),
count: 1,
};
save_json(&original, &path, &fs).unwrap();
fs.set_fail_on_rename(path.clone());
let new_data = TestData {
value: "new".to_string(),
count: 2,
};
let result = save_json(&new_data, &path, &fs);
assert!(result.is_err(), "expected Err when rename fails");
let loaded: TestData = load_json(&path, &fs).unwrap();
assert_eq!(loaded, original, "existing file should be intact after failed rename");
}
}