1use atomic_write_file::AtomicWriteFile;
2use std::{
3 fs,
4 io::{self, Write},
5 path::{Path, PathBuf},
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum WriteOutcome {
10 Created,
11 Updated,
12 Unchanged,
13 Skipped,
14}
15
16#[derive(Debug)]
17pub enum OutputError {
18 CreateDirectory { path: PathBuf, source: io::Error },
19 ReadExisting { path: PathBuf, source: io::Error },
20 CreateTemp { path: PathBuf, source: io::Error },
21 WriteTemp { path: PathBuf, source: io::Error },
22 Commit { path: PathBuf, source: io::Error },
23 Cleanup { path: PathBuf, source: io::Error },
24}
25
26impl std::fmt::Display for OutputError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 Self::CreateDirectory { path, source } => {
30 write!(
31 f,
32 "failed to create output directory {}: {source}",
33 path.display()
34 )
35 }
36 Self::ReadExisting { path, source } => {
37 write!(
38 f,
39 "failed to read existing output {}: {source}",
40 path.display()
41 )
42 }
43 Self::CreateTemp { path, source } => {
44 write!(
45 f,
46 "failed to create temp output {}: {source}",
47 path.display()
48 )
49 }
50 Self::WriteTemp { path, source } => {
51 write!(
52 f,
53 "failed to write temp output {}: {source}",
54 path.display()
55 )
56 }
57 Self::Commit { path, source } => write!(
58 f,
59 "failed to commit atomic output {}: {source}",
60 path.display()
61 ),
62 Self::Cleanup { path, source } => {
63 write!(
64 f,
65 "failed to clean up atomic output {}: {source}",
66 path.display()
67 )
68 }
69 }
70 }
71}
72
73impl std::error::Error for OutputError {}
74
75pub fn write_if_changed_atomic(path: &Path, contents: &str) -> Result<WriteOutcome, OutputError> {
76 if path.exists() {
77 let existing = fs::read_to_string(path).map_err(|source| OutputError::ReadExisting {
78 path: path.to_path_buf(),
79 source,
80 })?;
81 if existing == contents {
82 return Ok(WriteOutcome::Unchanged);
83 }
84 }
85
86 let parent = parent_dir(path);
87 fs::create_dir_all(parent).map_err(|source| OutputError::CreateDirectory {
88 path: parent.to_path_buf(),
89 source,
90 })?;
91
92 let mut atomic_file =
93 AtomicWriteFile::open(path).map_err(|source| OutputError::CreateTemp {
94 path: path.to_path_buf(),
95 source,
96 })?;
97 let destination_preexisted = path.exists();
98
99 atomic_file
100 .write_all(contents.as_bytes())
101 .and_then(|_| atomic_file.sync_all())
102 .map_err(|source| OutputError::WriteTemp {
103 path: path.to_path_buf(),
104 source,
105 })?;
106
107 atomic_file.commit().map_err(|source| OutputError::Commit {
108 path: path.to_path_buf(),
109 source,
110 })?;
111
112 let outcome = if destination_preexisted {
113 WriteOutcome::Updated
114 } else {
115 WriteOutcome::Created
116 };
117
118 Ok(outcome)
119}
120
121pub fn output_is_stale(path: &Path, contents: &str) -> Result<bool, OutputError> {
122 if !path.exists() {
123 return Ok(true);
124 }
125
126 let existing = fs::read_to_string(path).map_err(|source| OutputError::ReadExisting {
127 path: path.to_path_buf(),
128 source,
129 })?;
130
131 Ok(existing != contents)
132}
133
134fn parent_dir(path: &Path) -> &Path {
135 path.parent()
136 .filter(|parent| !parent.as_os_str().is_empty())
137 .unwrap_or_else(|| Path::new("."))
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::{
144 thread,
145 time::{Duration, SystemTime, UNIX_EPOCH},
146 };
147
148 fn make_temp_dir(test_name: &str) -> PathBuf {
149 let root = std::env::temp_dir().join(format!(
150 "numi-output-{test_name}-{}-{}",
151 std::process::id(),
152 SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .expect("clock should be after epoch")
155 .as_nanos()
156 ));
157 fs::create_dir_all(&root).expect("temp dir should be created");
158 root
159 }
160
161 #[test]
162 fn skips_rewrites_when_contents_are_unchanged() {
163 let root = make_temp_dir("noop");
164 let path = root.join("Generated/Assets.swift");
165
166 let first = write_if_changed_atomic(&path, "import SwiftUI\n").expect("first write");
167 let first_modified = fs::metadata(&path)
168 .expect("metadata should exist")
169 .modified()
170 .expect("mtime should be readable");
171
172 thread::sleep(Duration::from_millis(20));
173
174 let second = write_if_changed_atomic(&path, "import SwiftUI\n").expect("second write");
175 let second_modified = fs::metadata(&path)
176 .expect("metadata should exist")
177 .modified()
178 .expect("mtime should be readable");
179
180 assert_eq!(first, WriteOutcome::Created);
181 assert_eq!(second, WriteOutcome::Unchanged);
182 assert_eq!(first_modified, second_modified);
183
184 fs::remove_dir_all(root).expect("temp dir should be removed");
185 }
186
187 #[test]
188 fn replaces_existing_output_when_contents_change() {
189 let root = make_temp_dir("update");
190 let path = root.join("Generated/Assets.swift");
191
192 let first = write_if_changed_atomic(&path, "import SwiftUI\n")
193 .expect("initial write should succeed");
194 let first_modified = fs::metadata(&path)
195 .expect("metadata should exist")
196 .modified()
197 .expect("mtime should be readable");
198
199 thread::sleep(Duration::from_millis(20));
200
201 let second =
202 write_if_changed_atomic(&path, "import UIKit\n").expect("updated write should succeed");
203 let second_modified = fs::metadata(&path)
204 .expect("metadata should exist")
205 .modified()
206 .expect("mtime should be readable");
207 let contents = fs::read_to_string(&path).expect("updated contents should exist");
208
209 assert_eq!(first, WriteOutcome::Created);
210 assert_eq!(second, WriteOutcome::Updated);
211 assert_eq!(contents, "import UIKit\n");
212 assert!(second_modified >= first_modified);
213
214 fs::remove_dir_all(root).expect("temp dir should be removed");
215 }
216
217 #[test]
218 fn update_path_does_not_leave_sidecar_files() {
219 let root = make_temp_dir("sidecars");
220 let generated_dir = root.join("Generated");
221 let path = generated_dir.join("Assets.swift");
222
223 write_if_changed_atomic(&path, "import SwiftUI\n").expect("initial write should succeed");
224 write_if_changed_atomic(&path, "import UIKit\n").expect("update write should succeed");
225
226 let entries = fs::read_dir(&generated_dir)
227 .expect("generated dir should exist")
228 .map(|entry| entry.expect("dir entry should exist").file_name())
229 .collect::<Vec<_>>();
230
231 assert_eq!(entries.len(), 1);
232 assert_eq!(entries[0].to_string_lossy(), "Assets.swift");
233
234 fs::remove_dir_all(root).expect("temp dir should be removed");
235 }
236
237 #[test]
238 fn reports_missing_and_different_outputs_as_stale() {
239 let root = make_temp_dir("stale");
240 let path = root.join("Generated/Assets.swift");
241
242 assert!(
243 output_is_stale(&path, "import SwiftUI\n").expect("missing output should be stale")
244 );
245
246 write_if_changed_atomic(&path, "import SwiftUI\n").expect("initial write should succeed");
247 assert!(
248 !output_is_stale(&path, "import SwiftUI\n").expect("matching output should be fresh")
249 );
250 assert!(
251 output_is_stale(&path, "import UIKit\n").expect("different output should be stale")
252 );
253
254 fs::remove_dir_all(root).expect("temp dir should be removed");
255 }
256}