1use anyhow::{Context, Result};
2use serde::{Serialize, de::DeserializeOwned};
3use std::path::{Path, PathBuf};
4
5use crate::gateway::filesystem::Filesystem;
6
7pub fn load_json<T>(path: &Path, fs: &dyn Filesystem) -> Result<T>
8where
9 T: DeserializeOwned + Default,
10{
11 if !fs.exists(path) {
12 return Ok(T::default());
13 }
14 let content = fs.read_to_string(path)?;
15 let value: T = serde_json::from_str(&content)
16 .with_context(|| format!("Failed to parse {}", path.display()))?;
17 Ok(value)
18}
19
20pub fn tmp_path(path: &Path) -> PathBuf {
25 let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
26 name.push(".tmp");
27 path.with_file_name(name)
28}
29
30pub fn save_json<T>(value: &T, path: &Path, fs: &dyn Filesystem) -> Result<()>
37where
38 T: Serialize,
39{
40 if let Some(parent) = path.parent() {
41 fs.create_dir_all(parent)?;
42 }
43 let content = serde_json::to_string_pretty(value)?;
44 let tmp = tmp_path(path);
45 fs.write(&tmp, &content)?;
46 fs.rename(&tmp, path)?;
47 Ok(())
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use crate::gateway::fakes::FakeFilesystem;
54 use serde::{Deserialize, Serialize};
55 use std::path::PathBuf;
56
57 #[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
58 struct TestData {
59 value: String,
60 count: u32,
61 }
62
63 #[test]
64 fn load_json_returns_default_when_file_absent() {
65 let fs = FakeFilesystem::new();
66 let path = PathBuf::from("/nonexistent/data.json");
67 let result: TestData = load_json(&path, &fs).unwrap();
68 assert_eq!(result, TestData::default());
69 }
70
71 #[test]
72 fn load_json_round_trip_save_then_load() {
73 let fs = FakeFilesystem::new();
74 let path = PathBuf::from("/config/data.json");
75 let data = TestData {
76 value: "hello".to_string(),
77 count: 42,
78 };
79 save_json(&data, &path, &fs).unwrap();
80 let loaded: TestData = load_json(&path, &fs).unwrap();
81 assert_eq!(loaded, data);
82 }
83
84 #[test]
85 fn load_json_returns_error_on_malformed_json() {
86 let fs = FakeFilesystem::new();
87 let path = PathBuf::from("/config/data.json");
88 fs.add_file(path.clone(), "not json {{{{");
89 let result: Result<TestData> = load_json(&path, &fs);
90 assert!(result.is_err());
91 }
92
93 #[test]
94 fn tmp_path_appends_tmp_suffix() {
95 let path = PathBuf::from("/config/data.json");
96 assert_eq!(tmp_path(&path), PathBuf::from("/config/data.json.tmp"));
97 }
98
99 #[test]
100 fn save_json_failed_write_leaves_existing_file_intact() {
101 let fs = FakeFilesystem::new();
102 let path = PathBuf::from("/config/data.json");
103 let original = TestData {
104 value: "original".to_string(),
105 count: 1,
106 };
107 save_json(&original, &path, &fs).unwrap();
109
110 fs.set_fail_on_write(tmp_path(&path));
112
113 let new_data = TestData {
114 value: "new".to_string(),
115 count: 2,
116 };
117 let result = save_json(&new_data, &path, &fs);
118 assert!(result.is_err(), "expected Err when temp write fails");
119
120 let loaded: TestData = load_json(&path, &fs).unwrap();
122 assert_eq!(loaded, original, "existing file should be intact after failed temp write");
123 }
124
125 #[test]
126 fn save_json_failed_rename_leaves_existing_file_intact() {
127 let fs = FakeFilesystem::new();
128 let path = PathBuf::from("/config/data.json");
129 let original = TestData {
130 value: "original".to_string(),
131 count: 1,
132 };
133 save_json(&original, &path, &fs).unwrap();
135
136 fs.set_fail_on_rename(path.clone());
138
139 let new_data = TestData {
140 value: "new".to_string(),
141 count: 2,
142 };
143 let result = save_json(&new_data, &path, &fs);
144 assert!(result.is_err(), "expected Err when rename fails");
145
146 let loaded: TestData = load_json(&path, &fs).unwrap();
148 assert_eq!(loaded, original, "existing file should be intact after failed rename");
149 }
150}