Skip to main content

executable_stories/
json_writer.rs

1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use serde::Serialize;
7
8use crate::types::{RawRun, SCHEMA_URL};
9
10/// Tags a `RawRun` with the `$schema` pointer at write time. Keeping `$schema`
11/// out of the public `RawRun` struct means adding it never breaks a consumer's
12/// struct literal. `flatten` emits it as the first key, ahead of the run's own
13/// fields, so editors pick it up as they open the file.
14#[derive(Serialize)]
15struct SchemaTagged<'a> {
16    #[serde(rename = "$schema")]
17    schema: &'static str,
18    #[serde(flatten)]
19    run: &'a RawRun,
20}
21
22/// Distinguishes concurrent writes within one process. Named for the process
23/// alone, two threads writing different destinations share one scratch file.
24static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
25
26/// How many names to try before giving up on finding a free one.
27const TEMP_ATTEMPTS: u32 = 32;
28
29/// Create a scratch file next to the destination that no other write holds.
30///
31/// `create_new` fails rather than truncating when the name is taken, so a name
32/// already in use — by another thread, another process, or a crashed run that
33/// left one behind — is retried instead of silently shared.
34fn create_temp_file(parent: &Path) -> std::io::Result<(fs::File, PathBuf)> {
35    let pid = std::process::id();
36    for _ in 0..TEMP_ATTEMPTS {
37        let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
38        let path = parent.join(format!(".raw-run-{pid}-{sequence}.json"));
39        match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
40            Ok(file) => return Ok((file, path)),
41            Err(err) if err.kind() != std::io::ErrorKind::AlreadyExists => return Err(err),
42            // The name is taken; try the next one.
43            Err(_) => {}
44        }
45    }
46    Err(std::io::Error::new(
47        std::io::ErrorKind::AlreadyExists,
48        format!("no free scratch file in {} after {TEMP_ATTEMPTS} attempts", parent.display()),
49    ))
50}
51
52/// Write a `RawRun` to disk as pretty-printed JSON, with the `$schema` pointer.
53///
54/// Creates parent directories if they don't already exist. The file appears
55/// whole: it goes to a scratch file of its own and is renamed over the
56/// destination, so a reader never sees a half-written run and a failed write
57/// leaves the previous file intact.
58///
59/// # Errors
60///
61/// Returns an error if directory creation or file write fails.
62pub fn write_raw_run(run: &RawRun, output_path: &str) -> std::io::Result<()> {
63    let destination = Path::new(output_path);
64    let parent =
65        destination.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
66    fs::create_dir_all(parent)?;
67
68    let json = serde_json::to_string_pretty(&SchemaTagged { schema: SCHEMA_URL, run })?;
69    let (mut file, temp) = create_temp_file(parent)?;
70
71    // Closed before the rename: Windows will not rename a file that is open.
72    let written = file.write_all(json.as_bytes()).and_then(|()| file.flush());
73    drop(file);
74
75    let result = written.and_then(|()| fs::rename(&temp, destination));
76    if result.is_err() {
77        let _ = fs::remove_file(&temp);
78    }
79    result
80}
81
82#[cfg(test)]
83mod write_tests {
84    use super::write_raw_run;
85    use crate::types::{RawRun, RawTestCase};
86
87    fn run_titled(title: &str) -> RawRun {
88        RawRun {
89            schema_version: 1,
90            test_cases: vec![RawTestCase {
91                status: "pass".to_string(),
92                title: Some(title.to_string()),
93                source_file: Some("tests/a.rs".to_string()),
94                ..Default::default()
95            }],
96            features: Vec::new(),
97            project_root: "/tmp/project".to_string(),
98            started_at_ms: Some(1.0),
99            finished_at_ms: Some(2.0),
100            ci: None,
101            git_sha: None,
102            package_version: None,
103            run_scope: Some("full".to_string()),
104        }
105    }
106
107    #[test]
108    fn concurrent_writes_do_not_tread_on_each_other() {
109        // Every writer shares the destination's directory, so a scratch file
110        // named only for the process would be the same file for all of them.
111        let dir = std::env::temp_dir().join(format!("es-rust-concurrent-{}", std::process::id()));
112        let _ = std::fs::remove_dir_all(&dir);
113        std::fs::create_dir_all(&dir).unwrap();
114
115        let writers: Vec<_> = (0..16)
116            .map(|n| {
117                let output = dir.join(format!("raw-run-{n}.json"));
118                std::thread::spawn(move || {
119                    write_raw_run(&run_titled(&format!("scenario {n}")), output.to_str().unwrap())
120                        .map(|()| n)
121                })
122            })
123            .collect();
124
125        for writer in writers {
126            let n = writer.join().unwrap().expect("write failed");
127            let written: serde_json::Value = serde_json::from_str(
128                &std::fs::read_to_string(dir.join(format!("raw-run-{n}.json"))).unwrap(),
129            )
130            .unwrap();
131            assert_eq!(written["testCases"][0]["title"], format!("scenario {n}"));
132        }
133
134        // Every scratch file is renamed or cleaned up, so only the outputs remain.
135        let leftovers: Vec<_> = std::fs::read_dir(&dir)
136            .unwrap()
137            .filter_map(Result::ok)
138            .filter(|e| e.file_name().to_string_lossy().starts_with(".raw-run-"))
139            .collect();
140        assert!(leftovers.is_empty(), "{} scratch files left behind", leftovers.len());
141    }
142
143    #[test]
144    fn the_file_is_renamed_into_place() {
145        let dir = std::env::temp_dir().join(format!("es-rust-write-{}", std::process::id()));
146        let _ = std::fs::remove_dir_all(&dir);
147        let output = dir.join("raw-run.json");
148
149        write_raw_run(
150            &RawRun {
151                schema_version: 1,
152                test_cases: vec![RawTestCase {
153                    status: "pass".to_string(),
154                    source_file: Some("tests/a.rs".to_string()),
155                    ..Default::default()
156                }],
157                features: Vec::new(),
158                project_root: "/tmp/project".to_string(),
159                started_at_ms: Some(1.0),
160                finished_at_ms: Some(2.0),
161                ci: None,
162                git_sha: None,
163                package_version: None,
164                run_scope: Some("full".to_string()),
165            },
166            output.to_str().unwrap(),
167        )
168        .unwrap();
169
170        let written: serde_json::Value =
171            serde_json::from_str(&std::fs::read_to_string(&output).unwrap()).unwrap();
172        assert_eq!(written["schemaVersion"], 1);
173        // Renamed into place, so nothing is left alongside it.
174        let leftovers: Vec<_> = std::fs::read_dir(&dir)
175            .unwrap()
176            .filter_map(Result::ok)
177            .filter(|e| e.file_name().to_string_lossy().starts_with(".raw-run-"))
178            .collect();
179        assert!(leftovers.is_empty(), "temporary file left behind");
180    }
181}