reifydb_testing/testscript/
runner.rs1#[allow(clippy::disallowed_types)]
5use std::sync::Mutex;
6use std::{
7 collections::HashMap,
8 env::temp_dir,
9 error::Error,
10 fs, io,
11 io::Write as _,
12 panic, path, process,
13 sync::{Arc, LazyLock, PoisonError},
14 time,
15};
16
17use fs::read_to_string;
18use io::ErrorKind;
19use panic::AssertUnwindSafe;
20use path::Path;
21use time::SystemTime;
22
23use crate::{
24 goldenfile::Mint,
25 testscript::{
26 command::{Block, Command},
27 parser::parse,
28 },
29};
30
31#[allow(clippy::disallowed_types)]
32static SCRIPT_LOCKS: LazyLock<Mutex<HashMap<path::PathBuf, Arc<Mutex<()>>>>> =
33 LazyLock::new(|| Mutex::new(HashMap::new()));
34
35#[allow(clippy::disallowed_types)]
36fn lock_for(path: &Path) -> Arc<Mutex<()>> {
37 let mut map = SCRIPT_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
38 map.entry(path.to_path_buf()).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
39}
40
41pub trait Runner {
42 fn run(&mut self, command: &Command) -> Result<String, Box<dyn Error>>;
43
44 fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
45 Ok(())
46 }
47
48 fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
49 Ok(())
50 }
51
52 fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
53 Ok(String::new())
54 }
55
56 fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
57 Ok(String::new())
58 }
59
60 #[allow(unused_variables)]
61 fn start_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
62 Ok(String::new())
63 }
64
65 #[allow(unused_variables)]
66 fn end_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
67 Ok(String::new())
68 }
69}
70
71pub fn run_path<R: Runner, P: AsRef<Path>>(runner: &mut R, path: P) -> io::Result<()> {
72 let path = path.as_ref();
73 let Some(dir) = path.parent() else {
74 return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
75 };
76 let Some(filename) = path.file_name() else {
77 return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
78 };
79
80 if filename.to_str().unwrap().ends_with(".skip") {
81 return Ok(());
82 }
83
84 let lock = lock_for(path);
85 let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
86
87 let input = read_to_string(dir.join(filename))?;
88 let output = generate(runner, &input)?;
89
90 Mint::new(dir).new_goldenfile(filename)?.write_all(output.as_bytes())
91}
92
93pub fn run<R: Runner, S: Into<String>>(runner: R, test: S) {
94 try_run(runner, test).unwrap();
95}
96
97pub fn try_run<R: Runner, S: Into<String>>(mut runner: R, test: S) -> io::Result<()> {
98 let input = test.into();
99
100 let dir = temp_dir();
101 #[allow(clippy::disallowed_methods)]
102 let file_name = format!(
103 "test-{}-{}.txt",
104 process::id(),
105 SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos()
106 );
107 let file_path = dir.join(&file_name);
108
109 let mut file = fs::File::create(&file_path)?;
110 file.write_all(input.as_bytes())?;
111
112 let output = generate(&mut runner, &input)?;
113 Mint::new(dir).new_goldenfile(&file_name)?.write_all(output.as_bytes())
114}
115
116pub fn generate<R: Runner>(runner: &mut R, input: &str) -> io::Result<String> {
117 let mut output = String::with_capacity(input.len());
118 let eol = detect_eol(input);
119 let blocks = parse_blocks(input)?;
120
121 runner.start_script().map_err(|e| io::Error::other(format!("start_script failed: {e}")))?;
122
123 for (i, block) in blocks.iter().enumerate() {
124 if block.commands.is_empty() {
125 output.push_str(&block.literal);
126 continue;
127 }
128 let block_output = process_block(runner, block, eol)?;
129 output.push_str(&format!("{}---{eol}{}", block.literal, block_output));
130 if i < blocks.len() - 1 {
131 output.push_str(eol);
132 }
133 }
134
135 runner.end_script().map_err(|e| io::Error::other(format!("end_script failed: {e}")))?;
136 Ok(output)
137}
138
139#[inline]
140fn detect_eol(input: &str) -> &'static str {
141 if input.contains("\r\n") {
142 "\r\n"
143 } else {
144 "\n"
145 }
146}
147
148#[inline]
149fn parse_blocks(input: &str) -> io::Result<Vec<Block>> {
150 parse(input).map_err(|e| {
151 io::Error::new(
152 ErrorKind::InvalidInput,
153 format!(
154 "parse error at line {} column {} for {:?}:\n{}\n{}^",
155 e.input.location_line(),
156 e.input.get_column(),
157 e.code,
158 String::from_utf8_lossy(e.input.get_line_beginning()),
159 ' '.to_string().repeat(e.input.get_utf8_column() - 1)
160 ),
161 )
162 })
163}
164
165fn process_block<R: Runner>(runner: &mut R, block: &Block, eol: &str) -> io::Result<String> {
166 let mut block_output = String::new();
167 block_output.push_str(&ensure_eol(
168 runner.start_block().map_err(|e| {
169 io::Error::other(format!("start_block failed at line {}: {e}", block.line_number))
170 })?,
171 eol,
172 ));
173 for command in &block.commands {
174 let command_output = process_command(runner, command, eol)?;
175 block_output.push_str(&command_output);
176 }
177 block_output.push_str(&ensure_eol(
178 runner.end_block().map_err(|e| {
179 io::Error::other(format!("end_block failed at line {}: {e}", block.line_number))
180 })?,
181 eol,
182 ));
183 if block_output.is_empty() {
184 block_output.push_str("ok\n");
185 }
186 Ok(apply_blank_line_prefix(block_output))
187}
188
189fn process_command<R: Runner>(runner: &mut R, command: &Command, eol: &str) -> io::Result<String> {
190 let mut command_output = String::new();
191 command_output.push_str(&ensure_eol(
192 runner.start_command(command).map_err(|e| {
193 io::Error::other(format!("start_command failed at line {}: {e}", command.line_number))
194 })?,
195 eol,
196 ));
197 command_output.push_str(&run_command_with_panic_handling(runner, command)?);
198 command_output = ensure_eol(command_output, eol);
199 command_output.push_str(&ensure_eol(
200 runner.end_command(command).map_err(|e| {
201 io::Error::other(format!("end_command failed at line {}: {e}", command.line_number))
202 })?,
203 eol,
204 ));
205 if command.silent {
206 command_output.clear();
207 }
208 if let Some(prefix) = &command.prefix
209 && !command_output.is_empty()
210 {
211 command_output = format!(
212 "{prefix}: {}{eol}",
213 command_output
214 .strip_suffix(eol)
215 .unwrap_or(command_output.as_str())
216 .replace('\n', &format!("\n{prefix}: "))
217 );
218 }
219 Ok(command_output)
220}
221
222fn run_command_with_panic_handling<R: Runner>(runner: &mut R, command: &Command) -> io::Result<String> {
223 let run = AssertUnwindSafe(|| runner.run(command));
224 match panic::catch_unwind(run) {
225 Ok(Ok(output)) if command.fail => Err(io::Error::other(format!(
226 "expected command '{}' to fail at line {}, succeeded with: {output}",
227 command.name, command.line_number
228 ))),
229 Ok(Ok(output)) => Ok(output),
230 Ok(Err(e)) if command.fail => Ok(format!("{e}")),
231 Ok(Err(e)) => Err(io::Error::other(format!(
232 "command '{}' failed at line {}: {e}",
233 command.name, command.line_number
234 ))),
235 Err(panic) if command.fail => {
236 let message = panic
237 .downcast_ref::<&str>()
238 .map(|s| s.to_string())
239 .or_else(|| panic.downcast_ref::<String>().cloned())
240 .unwrap_or_else(|| panic::resume_unwind(panic));
241 Ok(format!("Panic: {message}"))
242 }
243 Err(panic) => panic::resume_unwind(panic),
244 }
245}
246
247#[inline]
248fn apply_blank_line_prefix(mut block_output: String) -> String {
249 if block_output.starts_with('\n')
250 || block_output.starts_with("\r\n")
251 || block_output.contains("\n\n")
252 || block_output.contains("\n\r\n")
253 {
254 block_output = format!("> {}", block_output.replace('\n', "\n> "));
255 block_output = block_output.replace("> \n", ">\n");
256 block_output.pop();
257 block_output.pop();
258 }
259 block_output
260}
261
262fn ensure_eol(mut s: String, eol: &str) -> String {
263 if let Some(c) = s.chars().next_back()
264 && c != '\n'
265 {
266 s.push_str(eol)
267 }
268 s
269}
270
271#[cfg(test)]
272pub mod tests {
273 use super::*;
274
275 #[derive(Default)]
278 struct HookRunner {
279 start_script_count: usize,
280 end_script_count: usize,
281 start_block_count: usize,
282 end_block_count: usize,
283 start_command_count: usize,
284 end_command_count: usize,
285 }
286
287 impl Runner for HookRunner {
288 fn run(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
289 Ok(String::new())
290 }
291
292 fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
293 self.start_script_count += 1;
294 Ok(())
295 }
296
297 fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
298 self.end_script_count += 1;
299 Ok(())
300 }
301
302 fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
303 self.start_block_count += 1;
304 Ok(String::new())
305 }
306
307 fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
308 self.end_block_count += 1;
309 Ok(String::new())
310 }
311
312 fn start_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
313 self.start_command_count += 1;
314 Ok(String::new())
315 }
316
317 fn end_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
318 self.end_command_count += 1;
319 Ok(String::new())
320 }
321 }
322
323 #[test]
325 fn hooks() {
326 let mut runner = HookRunner::default();
327 generate(
328 &mut runner,
329 r#"
330command
331---
332
333command
334command
335---
336"#,
337 )
338 .unwrap();
339
340 assert_eq!(runner.start_script_count, 1);
341 assert_eq!(runner.end_script_count, 1);
342 assert_eq!(runner.start_block_count, 2);
343 assert_eq!(runner.end_block_count, 2);
344 assert_eq!(runner.start_command_count, 3);
345 assert_eq!(runner.end_command_count, 3);
346 }
347}