1use anyhow::{Context, Result};
9use std::path::Path;
10use tokio::io::AsyncWriteExt;
11
12pub async fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
18 let parent = path.parent().unwrap_or_else(|| Path::new("."));
19 tokio::fs::create_dir_all(parent)
20 .await
21 .with_context(|| format!("creating {}", parent.display()))?;
22 let temp = parent.join(format!(".shine-write-{}", uuid::Uuid::new_v4()));
23
24 if let Err(error) = write_temp(&temp, contents).await {
25 let _ = tokio::fs::remove_file(&temp).await;
26 return Err(error);
27 }
28
29 finalize_temp(&temp, path).await
30}
31
32async fn write_temp(temp: &Path, contents: &[u8]) -> Result<()> {
33 let mut file = tokio::fs::File::create(temp)
34 .await
35 .with_context(|| format!("creating {}", temp.display()))?;
36 file.write_all(contents)
37 .await
38 .with_context(|| format!("writing {}", temp.display()))?;
39 file.sync_all()
40 .await
41 .with_context(|| format!("syncing {}", temp.display()))?;
42 Ok(())
43}
44
45pub async fn finalize_temp(temp: &Path, dest: &Path) -> Result<()> {
51 #[cfg(windows)]
52 if dest.exists() {
53 tokio::fs::remove_file(dest)
54 .await
55 .with_context(|| format!("removing {}", dest.display()))?;
56 }
57 if let Err(error) = tokio::fs::rename(temp, dest).await {
58 let _ = tokio::fs::remove_file(temp).await;
59 return Err(error).with_context(|| format!("replacing {}", dest.display()));
60 }
61 Ok(())
62}
63
64pub async fn load_toml_or_default<T>(path: &Path, what: &str) -> Result<T>
68where
69 T: serde::de::DeserializeOwned + Default,
70{
71 match tokio::fs::read_to_string(path).await {
72 Ok(content) => toml::from_str(&content).with_context(|| format!("failed to parse {what}")),
73 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
74 Err(e) => Err(e).with_context(|| format!("failed to read {what}")),
75 }
76}
77
78pub async fn save_toml_atomic<T: serde::Serialize>(
81 value: &T,
82 path: &Path,
83 what: &str,
84) -> Result<()> {
85 let content =
86 toml::to_string_pretty(value).with_context(|| format!("failed to serialize {what}"))?;
87 atomic_write(path, content.as_bytes())
88 .await
89 .with_context(|| format!("failed to write {what}"))
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[tokio::test]
97 async fn atomic_write_creates_missing_parent_directories() {
98 let dir = crate::test_support::make_temp_dir("shine-persist").await;
99 let path = dir.join("nested/deep/file.txt");
100
101 atomic_write(&path, b"hello").await.unwrap();
102
103 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"hello");
104 tokio::fs::remove_dir_all(&dir).await.unwrap();
105 }
106
107 #[tokio::test]
108 async fn atomic_write_replaces_existing_file() {
109 let dir = crate::test_support::make_temp_dir("shine-persist").await;
110 let path = dir.join("file.txt");
111 tokio::fs::write(&path, b"old").await.unwrap();
112
113 atomic_write(&path, b"new").await.unwrap();
114
115 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"new");
116 tokio::fs::remove_dir_all(&dir).await.unwrap();
117 }
118
119 #[tokio::test]
120 async fn atomic_write_leaves_no_temp_file_behind_on_success() {
121 let dir = crate::test_support::make_temp_dir("shine-persist").await;
122 let path = dir.join("file.txt");
123
124 atomic_write(&path, b"content").await.unwrap();
125
126 let mut entries = tokio::fs::read_dir(&dir).await.unwrap();
127 let mut names = Vec::new();
128 while let Some(entry) = entries.next_entry().await.unwrap() {
129 names.push(entry.file_name());
130 }
131 assert_eq!(names, vec![std::ffi::OsString::from("file.txt")]);
132 tokio::fs::remove_dir_all(&dir).await.unwrap();
133 }
134
135 #[tokio::test]
136 async fn finalize_temp_removes_temp_on_rename_failure() {
137 let dir = crate::test_support::make_temp_dir("shine-persist").await;
138 let temp = dir.join(".shine-write-test");
139 tokio::fs::write(&temp, b"content").await.unwrap();
140 let dest = dir.join("missing-dir").join("dest.txt");
142
143 let result = finalize_temp(&temp, &dest).await;
144
145 assert!(result.is_err());
146 assert!(!temp.exists(), "temp file should be cleaned up on failure");
147 tokio::fs::remove_dir_all(&dir).await.unwrap();
148 }
149
150 #[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
151 struct SampleToml {
152 #[serde(default)]
153 name: String,
154 #[serde(default)]
155 count: u32,
156 }
157
158 #[tokio::test]
159 async fn load_toml_or_default_returns_default_when_file_missing() {
160 let dir = crate::test_support::make_temp_dir("shine-persist").await;
161 let path = dir.join("sample.toml");
162
163 let value: SampleToml = load_toml_or_default(&path, "sample").await.unwrap();
164
165 assert_eq!(value, SampleToml::default());
166 tokio::fs::remove_dir_all(&dir).await.unwrap();
167 }
168
169 #[tokio::test]
170 async fn save_then_load_toml_round_trips() {
171 let dir = crate::test_support::make_temp_dir("shine-persist").await;
172 let path = dir.join("sample.toml");
173 let value = SampleToml {
174 name: "hi".to_string(),
175 count: 3,
176 };
177
178 save_toml_atomic(&value, &path, "sample").await.unwrap();
179 let loaded: SampleToml = load_toml_or_default(&path, "sample").await.unwrap();
180
181 assert_eq!(loaded, value);
182 tokio::fs::remove_dir_all(&dir).await.unwrap();
183 }
184
185 #[tokio::test]
186 async fn save_toml_atomic_creates_missing_parent_directory() {
187 let dir = crate::test_support::make_temp_dir("shine-persist").await;
188 let path = dir.join("nested/sample.toml");
189 let value = SampleToml::default();
190
191 save_toml_atomic(&value, &path, "sample").await.unwrap();
192
193 assert!(path.exists());
194 tokio::fs::remove_dir_all(&dir).await.unwrap();
195 }
196}