executable_stories/
json_writer.rs1use 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#[derive(Serialize)]
15struct SchemaTagged<'a> {
16 #[serde(rename = "$schema")]
17 schema: &'static str,
18 #[serde(flatten)]
19 run: &'a RawRun,
20}
21
22static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
25
26const TEMP_ATTEMPTS: u32 = 32;
28
29fn 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 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
52pub 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 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 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 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 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}