Skip to main content

numi_core/
output.rs

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}