1use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::Serialize;
9use uuid::Uuid;
10
11pub fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> {
14 atomic_write_json_with_perms(path, value, false)
15}
16
17pub fn atomic_write_json_with_perms(
20 path: &Path,
21 value: &impl Serialize,
22 restrict_perms: bool,
23) -> Result<()> {
24 if let Some(parent) = path.parent() {
25 fs::create_dir_all(parent)?;
26 }
27 let tmp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
28 let payload = serde_json::to_vec_pretty(value)?;
29 fs::write(&tmp, &payload)?;
30 if restrict_perms {
31 #[cfg(unix)]
32 {
33 use std::os::unix::fs::PermissionsExt;
34 let perms = fs::Permissions::from_mode(0o600);
35 let _ = fs::set_permissions(&tmp, perms);
36 }
37 }
38 fs::rename(&tmp, path)
39 .with_context(|| format!("failed to atomically write {}", path.display()))?;
40 Ok(())
41}
42
43pub fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
45 match fs::read_to_string(path) {
46 Ok(raw) => {
47 if raw.trim().is_empty() {
48 return Ok(None);
49 }
50 let value = serde_json::from_str(&raw)?;
51 Ok(Some(value))
52 }
53 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
54 Err(err) => Err(err.into()),
55 }
56}
57
58pub fn remove_file_if_exists(path: &Path) -> Result<()> {
60 match fs::remove_file(path) {
61 Ok(()) => Ok(()),
62 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
63 Err(err) => Err(err.into()),
64 }
65}
66
67pub fn tmp_path_for(path: &Path) -> PathBuf {
69 path.with_extension(format!("{}.tmp", Uuid::new_v4()))
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use serde::Deserialize;
76
77 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78 struct TestData {
79 key: String,
80 value: i32,
81 }
82
83 fn temp_path(label: &str) -> PathBuf {
84 let nanos = std::time::SystemTime::now()
85 .duration_since(std::time::UNIX_EPOCH)
86 .unwrap_or_default()
87 .as_nanos();
88 std::env::temp_dir().join(format!(
89 "beam-persist-test-{}-{}-{}",
90 label,
91 nanos,
92 std::process::id()
93 ))
94 }
95
96 #[test]
97 fn atomic_write_and_read_roundtrip() {
98 let path = temp_path("roundtrip");
99 let data = TestData {
100 key: "hello".to_string(),
101 value: 42,
102 };
103 atomic_write_json(&path, &data).unwrap();
104 let loaded: Option<TestData> = read_json(&path).unwrap();
105 assert_eq!(loaded, Some(data));
106 let _ = fs::remove_file(&path);
107 }
108
109 #[test]
110 fn read_json_returns_none_for_missing_file() {
111 let path = temp_path("missing");
112 let result: Option<TestData> = read_json(&path).unwrap();
113 assert!(result.is_none());
114 }
115
116 #[test]
117 fn atomic_write_with_restrict_perms() {
118 let path = temp_path("perms");
119 let data = TestData {
120 key: "secret".to_string(),
121 value: 99,
122 };
123 atomic_write_json_with_perms(&path, &data, true).unwrap();
124 let loaded: Option<TestData> = read_json(&path).unwrap();
125 assert_eq!(loaded, Some(data));
126 #[cfg(unix)]
127 {
128 use std::os::unix::fs::PermissionsExt;
129 let metadata = fs::metadata(&path).unwrap();
130 let mode = metadata.permissions().mode() & 0o777;
131 assert_eq!(mode, 0o600, "expected 0600 permissions, got {:o}", mode);
132 }
133 let _ = fs::remove_file(&path);
134 }
135
136 #[test]
137 fn remove_file_if_exists_ok() {
138 let path = temp_path("rm");
139 fs::write(&path, "test").unwrap();
140 remove_file_if_exists(&path).unwrap();
141 assert!(!path.exists());
142 remove_file_if_exists(&path).unwrap();
144 }
145}