Skip to main content

cmx_core/
json_file.rs

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
20/// Return the sibling temporary path used during an atomic write of `path`.
21///
22/// The temp path is `path` with `.tmp` appended to the file name, so it sits
23/// in the same directory and can be renamed atomically onto the target.
24pub 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
30/// Write `value` as pretty-printed JSON to `path` atomically.
31///
32/// The JSON is first written to a sibling `.tmp` file in the same directory,
33/// then renamed onto `path`.  This ensures that a partially-written or failed
34/// write never corrupts an existing file: the rename only happens after the
35/// write succeeds.
36pub 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        // Write the original file first
108        save_json(&original, &path, &fs).unwrap();
109
110        // Now cause the temp-file write to fail
111        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        // The existing file should be unmodified
121        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        // Write the original file first
134        save_json(&original, &path, &fs).unwrap();
135
136        // Cause the rename (onto the final path) to fail
137        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        // The existing file should be unmodified
147        let loaded: TestData = load_json(&path, &fs).unwrap();
148        assert_eq!(loaded, original, "existing file should be intact after failed rename");
149    }
150}