Skip to main content

utils/
fs.rs

1use serde::Serialize;
2use serde::de::DeserializeOwned;
3use std::fs::{self, OpenOptions};
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use tempfile::NamedTempFile;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum JsonFileError {
11    #[error("failed to access {}: {source}", .path.display())]
12    Io { path: PathBuf, source: io::Error },
13    #[error("invalid JSON in {}: {source}", .path.display())]
14    Json { path: PathBuf, source: serde_json::Error },
15}
16
17pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
18    let parent = create_parent_dir(path)?;
19    let mut temp = NamedTempFile::new_in(parent)?;
20    temp.write_all(bytes)?;
21    if let Ok(existing) = fs::metadata(path) {
22        temp.as_file().set_permissions(existing.permissions())?;
23    }
24    temp.as_file().sync_all()?;
25    temp.persist(path).map_err(|error| error.error)?;
26    Ok(())
27}
28
29pub fn append_line(path: &Path, line: &str) -> io::Result<()> {
30    create_parent_dir(path)?;
31    OpenOptions::new().create(true).append(true).open(path)?.write_all(format!("{line}\n").as_bytes())
32}
33
34pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, JsonFileError> {
35    match fs::read(path) {
36        Ok(bytes) => serde_json::from_slice(&bytes)
37            .map(Some)
38            .map_err(|source| JsonFileError::Json { path: path.to_path_buf(), source }),
39        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
40        Err(source) => Err(JsonFileError::Io { path: path.to_path_buf(), source }),
41    }
42}
43
44pub fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), JsonFileError> {
45    let mut bytes =
46        serde_json::to_vec_pretty(value).map_err(|source| JsonFileError::Json { path: path.to_path_buf(), source })?;
47    bytes.push(b'\n');
48    write_atomic(path, &bytes).map_err(|source| JsonFileError::Io { path: path.to_path_buf(), source })
49}
50
51pub fn with_file_lock<T, E: From<io::Error>>(path: &Path, f: impl FnOnce() -> Result<T, E>) -> Result<T, E> {
52    create_parent_dir(path)?;
53    let mut lock_path = path.as_os_str().to_owned();
54    lock_path.push(".lock");
55    let lock = OpenOptions::new().create(true).truncate(false).write(true).open(lock_path)?;
56    lock.lock()?;
57    f()
58}
59
60fn create_parent_dir(path: &Path) -> io::Result<&Path> {
61    let parent = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."));
62    fs::create_dir_all(parent)?;
63    Ok(parent)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use tempfile::TempDir;
70
71    #[test]
72    fn append_line_creates_parents_and_appends_newline_terminated_lines() {
73        let dir = TempDir::new().unwrap();
74        let path = dir.path().join("nested/log.jsonl");
75
76        append_line(&path, "first").unwrap();
77        append_line(&path, "second").unwrap();
78
79        assert_eq!(fs::read_to_string(&path).unwrap(), "first\nsecond\n");
80    }
81
82    #[test]
83    fn json_round_trips_and_missing_files_read_as_none() {
84        let dir = TempDir::new().unwrap();
85        let path = dir.path().join("nested/data.json");
86
87        assert_eq!(read_json::<Vec<u32>>(&path).unwrap(), None);
88        write_json_atomic(&path, &[1, 2]).unwrap();
89
90        assert_eq!(read_json::<Vec<u32>>(&path).unwrap(), Some(vec![1, 2]));
91        assert!(fs::read_to_string(&path).unwrap().ends_with("]\n"));
92    }
93
94    #[test]
95    fn read_json_reports_malformed_files_with_their_path() {
96        let dir = TempDir::new().unwrap();
97        let path = dir.path().join("data.json");
98        fs::write(&path, "{").unwrap();
99
100        let error = read_json::<Vec<u32>>(&path).unwrap_err();
101
102        assert!(matches!(error, JsonFileError::Json { path: ref failed, .. } if failed == &path));
103    }
104
105    #[test]
106    fn with_file_lock_runs_the_closure_and_can_be_retaken() {
107        let dir = TempDir::new().unwrap();
108        let path = dir.path().join("nested/data.json");
109
110        let first: io::Result<u32> = with_file_lock(&path, || Ok(1));
111        let second: io::Result<u32> = with_file_lock(&path, || Ok(2));
112
113        assert_eq!((first.unwrap(), second.unwrap()), (1, 2));
114        assert!(dir.path().join("nested/data.json.lock").is_file());
115    }
116
117    #[test]
118    fn write_atomic_creates_parents_and_replaces_contents() {
119        let dir = TempDir::new().unwrap();
120        let path = dir.path().join("nested/file.json");
121
122        write_atomic(&path, b"first").unwrap();
123        write_atomic(&path, b"second").unwrap();
124
125        assert_eq!(fs::read_to_string(&path).unwrap(), "second");
126        let entries: Vec<_> = fs::read_dir(path.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name()).collect();
127        assert_eq!(entries, ["file.json"], "temp files should not be left behind");
128    }
129
130    #[cfg(unix)]
131    #[test]
132    fn write_atomic_creates_private_files_and_keeps_existing_permissions() {
133        use std::os::unix::fs::PermissionsExt;
134        let dir = TempDir::new().unwrap();
135        let path = dir.path().join("file");
136        let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
137
138        write_atomic(&path, b"first").unwrap();
139        assert_eq!(mode(&path), 0o600);
140
141        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
142        write_atomic(&path, b"second").unwrap();
143        assert_eq!(mode(&path), 0o640);
144    }
145
146    #[test]
147    fn write_atomic_failure_leaves_no_temp_file() {
148        let dir = TempDir::new().unwrap();
149        let path = dir.path().join("target");
150        fs::create_dir_all(path.join("occupied")).unwrap();
151
152        assert!(write_atomic(&path, b"data").is_err());
153
154        let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect();
155        assert_eq!(entries, ["target"], "the temp file should be removed after a failed rename");
156    }
157}