1use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12pub fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
19 let target = resolve(path);
20 let temp = temp_path(&target)?;
21 write_and_replace(&temp, &target, contents).inspect_err(|_| {
22 let _ = std::fs::remove_file(&temp);
25 })
26}
27
28fn resolve(path: &Path) -> PathBuf {
32 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
33}
34
35fn temp_path(target: &Path) -> std::io::Result<PathBuf> {
38 let name = target.file_name().ok_or_else(|| {
39 std::io::Error::new(
40 std::io::ErrorKind::InvalidInput,
41 format!("{} names no file to write", target.display()),
42 )
43 })?;
44 let mut temp = std::ffi::OsString::from(".");
45 temp.push(name);
46 temp.push(".tmp");
47 Ok(target.with_file_name(temp))
48}
49
50fn write_and_replace(temp: &Path, target: &Path, contents: &[u8]) -> std::io::Result<()> {
51 let mut file = std::fs::File::create(temp)?;
52 file.write_all(contents)?;
53 file.sync_all()?;
56 drop(file);
57 std::fs::rename(temp, target)?;
58 sync_dir(parent_dir(target))
59}
60
61fn parent_dir(target: &Path) -> &Path {
64 match target.parent() {
65 Some(dir) if !dir.as_os_str().is_empty() => dir,
66 _ => Path::new("."),
67 }
68}
69
70#[cfg(unix)]
73fn sync_dir(dir: &Path) -> std::io::Result<()> {
74 std::fs::File::open(dir)?.sync_all()
75}
76
77#[cfg(not(unix))]
80fn sync_dir(_dir: &Path) -> std::io::Result<()> {
81 Ok(())
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 struct TempDir(PathBuf);
91
92 impl TempDir {
93 fn new(name: &str) -> Self {
94 let dir = std::env::temp_dir().join(format!("blockworx-{}-{name}", std::process::id()));
95 let _ = std::fs::remove_dir_all(&dir);
96 std::fs::create_dir_all(&dir).unwrap();
97 Self(dir)
98 }
99
100 fn join(&self, name: &str) -> PathBuf {
101 self.0.join(name)
102 }
103 }
104
105 impl Drop for TempDir {
106 fn drop(&mut self) {
107 let _ = std::fs::remove_dir_all(&self.0);
108 }
109 }
110
111 fn read(path: &Path) -> String {
112 std::fs::read_to_string(path).unwrap()
113 }
114
115 #[test]
116 fn it_creates_and_then_overwrites() {
117 let dir = TempDir::new("create-overwrite");
118 let doc = dir.join("doc.kdl");
119
120 write_atomically(&doc, b"first").unwrap();
121 assert_eq!(read(&doc), "first");
122
123 write_atomically(&doc, b"second").unwrap();
124 assert_eq!(read(&doc), "second");
125 }
126
127 #[test]
128 fn a_successful_write_leaves_no_temp_behind() {
129 let dir = TempDir::new("no-temp");
130 let doc = dir.join("doc.kdl");
131 write_atomically(&doc, b"content").unwrap();
132
133 let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
134 .unwrap()
135 .map(|e| e.unwrap().file_name())
136 .filter(|name| name != "doc.kdl")
137 .collect();
138 assert!(leftovers.is_empty(), "left {leftovers:?} behind");
139 }
140
141 #[test]
146 fn a_failed_write_leaves_the_previous_contents_intact() {
147 let dir = TempDir::new("failed-write");
148 let doc = dir.join("doc.kdl");
149 write_atomically(&doc, b"the good version").unwrap();
150
151 let blocker = dir.join(".doc.kdl.tmp");
152 std::fs::create_dir(&blocker).unwrap();
153 assert_eq!(temp_path(&resolve(&doc)).unwrap(), blocker);
155
156 assert!(write_atomically(&doc, b"a doomed longer version").is_err());
157 assert_eq!(read(&doc), "the good version");
158 }
159
160 #[test]
161 fn a_path_naming_no_file_is_an_error() {
162 assert!(write_atomically(Path::new(".."), b"x").is_err());
163 }
164
165 #[cfg(unix)]
168 #[test]
169 fn writing_a_symlink_updates_its_target() {
170 let dir = TempDir::new("symlink");
171 let target = dir.join("real.kdl");
172 let link = dir.join("link.kdl");
173 write_atomically(&target, b"before").unwrap();
174 std::os::unix::fs::symlink(&target, &link).unwrap();
175
176 write_atomically(&link, b"after").unwrap();
177
178 assert!(
179 std::fs::symlink_metadata(&link).unwrap().is_symlink(),
180 "the link itself was replaced"
181 );
182 assert_eq!(read(&target), "after");
183 }
184}