envelope_cli/storage/
file_io.rs

1//! File I/O utilities with atomic writes
2//!
3//! Provides safe file operations that won't corrupt data on failure.
4
5use std::fs::{self, File};
6use std::io::{BufReader, BufWriter, Write};
7use std::path::Path;
8
9use serde::{de::DeserializeOwned, Serialize};
10
11use crate::error::EnvelopeError;
12
13/// Read JSON from a file, returning a default value if file doesn't exist
14pub fn read_json<T, P>(path: P) -> Result<T, EnvelopeError>
15where
16    T: DeserializeOwned + Default,
17    P: AsRef<Path>,
18{
19    let path = path.as_ref();
20
21    if !path.exists() {
22        return Ok(T::default());
23    }
24
25    let file = File::open(path)
26        .map_err(|e| EnvelopeError::Storage(format!("Failed to open {}: {}", path.display(), e)))?;
27
28    let reader = BufReader::new(file);
29    serde_json::from_reader(reader)
30        .map_err(|e| EnvelopeError::Storage(format!("Failed to parse {}: {}", path.display(), e)))
31}
32
33/// Read JSON from a file, returning an error if file doesn't exist
34pub fn read_json_required<T, P>(path: P) -> Result<T, EnvelopeError>
35where
36    T: DeserializeOwned,
37    P: AsRef<Path>,
38{
39    let path = path.as_ref();
40
41    if !path.exists() {
42        return Err(EnvelopeError::Storage(format!(
43            "File not found: {}",
44            path.display()
45        )));
46    }
47
48    let file = File::open(path)
49        .map_err(|e| EnvelopeError::Storage(format!("Failed to open {}: {}", path.display(), e)))?;
50
51    let reader = BufReader::new(file);
52    serde_json::from_reader(reader)
53        .map_err(|e| EnvelopeError::Storage(format!("Failed to parse {}: {}", path.display(), e)))
54}
55
56/// Write JSON to a file atomically (write to temp, then rename)
57///
58/// This ensures that the file is either completely written or not modified at all,
59/// preventing corruption on crashes or power failures.
60pub fn write_json_atomic<T, P>(path: P, data: &T) -> Result<(), EnvelopeError>
61where
62    T: Serialize,
63    P: AsRef<Path>,
64{
65    let path = path.as_ref();
66
67    // Ensure parent directory exists
68    if let Some(parent) = path.parent() {
69        fs::create_dir_all(parent).map_err(|e| {
70            EnvelopeError::Storage(format!(
71                "Failed to create directory {}: {}",
72                parent.display(),
73                e
74            ))
75        })?;
76    }
77
78    // Create temp file in same directory (important for atomic rename)
79    let temp_path = path.with_extension("json.tmp");
80
81    // Write to temp file
82    let file = File::create(&temp_path)
83        .map_err(|e| EnvelopeError::Storage(format!("Failed to create temp file: {}", e)))?;
84
85    let mut writer = BufWriter::new(file);
86    serde_json::to_writer_pretty(&mut writer, data)
87        .map_err(|e| EnvelopeError::Storage(format!("Failed to serialize data: {}", e)))?;
88
89    writer
90        .flush()
91        .map_err(|e| EnvelopeError::Storage(format!("Failed to flush data: {}", e)))?;
92
93    // Sync to disk before rename
94    writer
95        .get_ref()
96        .sync_all()
97        .map_err(|e| EnvelopeError::Storage(format!("Failed to sync data: {}", e)))?;
98
99    // Atomic rename
100    fs::rename(&temp_path, path).map_err(|e| {
101        // Try to clean up temp file if rename fails
102        let _ = fs::remove_file(&temp_path);
103        EnvelopeError::Storage(format!("Failed to rename temp file: {}", e))
104    })?;
105
106    Ok(())
107}
108
109/// Check if a JSON file exists and is valid
110pub fn json_file_valid<P: AsRef<Path>>(path: P) -> bool {
111    let path = path.as_ref();
112    if !path.exists() {
113        return false;
114    }
115
116    // Try to parse as JSON
117    if let Ok(file) = File::open(path) {
118        let reader = BufReader::new(file);
119        serde_json::from_reader::<_, serde_json::Value>(reader).is_ok()
120    } else {
121        false
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use serde::{Deserialize, Serialize};
129    use tempfile::TempDir;
130
131    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
132    struct TestData {
133        name: String,
134        value: i32,
135    }
136
137    #[test]
138    fn test_read_nonexistent_returns_default() {
139        let temp_dir = TempDir::new().unwrap();
140        let path = temp_dir.path().join("nonexistent.json");
141
142        let data: TestData = read_json(&path).unwrap();
143        assert_eq!(data, TestData::default());
144    }
145
146    #[test]
147    fn test_write_and_read() {
148        let temp_dir = TempDir::new().unwrap();
149        let path = temp_dir.path().join("test.json");
150
151        let data = TestData {
152            name: "test".to_string(),
153            value: 42,
154        };
155
156        write_json_atomic(&path, &data).unwrap();
157        assert!(path.exists());
158
159        let loaded: TestData = read_json(&path).unwrap();
160        assert_eq!(data, loaded);
161    }
162
163    #[test]
164    fn test_atomic_write_no_temp_file_left() {
165        let temp_dir = TempDir::new().unwrap();
166        let path = temp_dir.path().join("test.json");
167        let temp_path = temp_dir.path().join("test.json.tmp");
168
169        let data = TestData {
170            name: "test".to_string(),
171            value: 42,
172        };
173
174        write_json_atomic(&path, &data).unwrap();
175
176        assert!(path.exists());
177        assert!(!temp_path.exists());
178    }
179
180    #[test]
181    fn test_write_creates_parent_directories() {
182        let temp_dir = TempDir::new().unwrap();
183        let path = temp_dir.path().join("nested").join("dir").join("test.json");
184
185        let data = TestData {
186            name: "test".to_string(),
187            value: 42,
188        };
189
190        write_json_atomic(&path, &data).unwrap();
191        assert!(path.exists());
192    }
193
194    #[test]
195    fn test_json_file_valid() {
196        let temp_dir = TempDir::new().unwrap();
197        let valid_path = temp_dir.path().join("valid.json");
198        let invalid_path = temp_dir.path().join("invalid.json");
199        let nonexistent_path = temp_dir.path().join("nonexistent.json");
200
201        // Create valid JSON
202        fs::write(&valid_path, r#"{"name": "test"}"#).unwrap();
203        assert!(json_file_valid(&valid_path));
204
205        // Create invalid JSON
206        fs::write(&invalid_path, "not json at all").unwrap();
207        assert!(!json_file_valid(&invalid_path));
208
209        // Nonexistent
210        assert!(!json_file_valid(&nonexistent_path));
211    }
212
213    #[test]
214    fn test_read_json_required() {
215        let temp_dir = TempDir::new().unwrap();
216        let path = temp_dir.path().join("test.json");
217
218        // Should fail for nonexistent
219        assert!(read_json_required::<TestData, _>(&path).is_err());
220
221        // Write and then read should work
222        let data = TestData {
223            name: "test".to_string(),
224            value: 42,
225        };
226        write_json_atomic(&path, &data).unwrap();
227
228        let loaded: TestData = read_json_required(&path).unwrap();
229        assert_eq!(data, loaded);
230    }
231}