Skip to main content

reifydb_testing/testscript/
runner.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 ReifyDB
3
4// This file includes and modifies code from the toydb project (https://github.com/erikgrinaker/toydb),
5// originally licensed under the Apache License, Version 2.0.
6// Original copyright:
7//   Copyright (c) 2024 Erik Grinaker
8//
9// The original Apache License can be found at:
10//   http://www.apache.org/licenses/LICENSE-2.0
11
12use std::{env::temp_dir, error::Error, fs, io, io::Write as _, panic, path, process, time};
13
14use fs::read_to_string;
15use io::ErrorKind;
16use panic::AssertUnwindSafe;
17use path::Path;
18use time::SystemTime;
19
20use crate::{
21	goldenfile::Mint,
22	testscript::{command::Command, parser::parse},
23};
24
25/// Runs testscript commands, returning their output.
26pub trait Runner {
27	/// Runs a testscript command, returning its output, or an error if the
28	/// command fails.
29	///
30	/// Arguments can be accessed directly via [`Command::args`], or by
31	/// using the [`Command::consume_args`] helper for more convenient
32	/// processing.
33	///
34	/// Error cases are typically tested by running the command with a `!`
35	/// prefix (expecting a failure), but the runner can also handle these
36	/// itself and return an `Ok` result with appropriate output.
37	fn run(&mut self, command: &Command) -> Result<String, Box<dyn Error>>;
38
39	/// Called at the start of a testscript. Used e.g. for initial setup.
40	/// Can't return output, since it's not called in the context of a
41	/// block.
42	fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
43		Ok(())
44	}
45
46	/// Called at the end of a testscript. Used e.g. for state assertions.
47	/// Can't return output, since it's not called in the context of a
48	/// block.
49	fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
50		Ok(())
51	}
52
53	/// Called at the start of a block. Used e.g. to output initial state.
54	/// Any output is prepended to the block's output.
55	fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
56		Ok(String::new())
57	}
58
59	/// Called at the end of a block. Used e.g. to output final state.
60	/// Any output is appended to the block's output.
61	fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
62		Ok(String::new())
63	}
64
65	/// Called at the start of a command. Used e.g. for setup. Any output is
66	/// prepended to the command's output, and is affected e.g. by the
67	/// prefix and silencing of the command.
68	#[allow(unused_variables)]
69	fn start_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
70		Ok(String::new())
71	}
72
73	/// Called at the end of a command. Used e.g. for cleanup. Any output is
74	/// appended to the command's output, and is affected e.g. by the prefix
75	/// and silencing of the command.
76	#[allow(unused_variables)]
77	fn end_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
78		Ok(String::new())
79	}
80}
81
82/// Runs a testscript at the given path.
83///
84/// Panics if the script output differs from the current input file. Errors on
85/// IO, parser, or runner failure. If the environment variable
86/// `UPDATE_TESTFILES=1` is set, the new output file will replace the input
87/// file.
88pub fn run_path<R: Runner, P: AsRef<Path>>(runner: &mut R, path: P) -> io::Result<()> {
89	let path = path.as_ref();
90	let Some(dir) = path.parent() else {
91		return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
92	};
93	let Some(filename) = path.file_name() else {
94		return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
95	};
96
97	if filename.to_str().unwrap().ends_with(".skip") {
98		return Ok(());
99	}
100
101	let input = read_to_string(dir.join(filename))?;
102	let output = generate(runner, &input)?;
103
104	Mint::new(dir).new_goldenfile(filename)?.write_all(output.as_bytes())
105}
106
107pub fn run<R: Runner, S: Into<String>>(runner: R, test: S) {
108	try_run(runner, test).unwrap();
109}
110
111pub fn try_run<R: Runner, S: Into<String>>(mut runner: R, test: S) -> io::Result<()> {
112	let input = test.into();
113
114	let dir = temp_dir();
115	let file_name = format!(
116		"test-{}-{}.txt",
117		process::id(),
118		SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos()
119	);
120	let file_path = dir.join(&file_name);
121
122	let mut file = fs::File::create(&file_path)?;
123	file.write_all(input.as_bytes())?;
124
125	let output = generate(&mut runner, &input)?;
126	Mint::new(dir).new_goldenfile(&file_name)?.write_all(output.as_bytes())
127}
128
129/// Generates output for a testscript input, without comparing them.
130pub fn generate<R: Runner>(runner: &mut R, input: &str) -> io::Result<String> {
131	let mut output = String::with_capacity(input.len()); // common case: output == input
132
133	// Detect end-of-line format.
134	let eol = match input.find("\r\n") {
135		Some(_) => "\r\n",
136		None => "\n",
137	};
138
139	// Parse the script.
140	let blocks = parse(input).map_err(|e| {
141		io::Error::new(
142			ErrorKind::InvalidInput,
143			format!(
144				"parse error at line {} column {} for {:?}:\n{}\n{}^",
145				e.input.location_line(),
146				e.input.get_column(),
147				e.code,
148				String::from_utf8_lossy(e.input.get_line_beginning()),
149				' '.to_string().repeat(e.input.get_utf8_column() - 1)
150			),
151		)
152	})?;
153
154	// Call the start_script() hook.
155	runner.start_script().map_err(|e| io::Error::new(ErrorKind::Other, format!("start_script failed: {e}")))?;
156
157	for (i, block) in blocks.iter().enumerate() {
158		// There may be a trailing block with no commands if the script
159		// has bare comments at the end. If so, just retain its
160		// literal contents.
161		if block.commands.is_empty() {
162			output.push_str(&block.literal);
163			continue;
164		}
165
166		// Process each block of commands and accumulate their output.
167		let mut block_output = String::new();
168
169		// Call the start_block() hook.
170		block_output.push_str(&ensure_eol(
171			runner.start_block().map_err(|e| {
172				io::Error::new(
173					ErrorKind::Other,
174					format!("start_block failed at line {}: {e}", block.line_number),
175				)
176			})?,
177			eol,
178		));
179
180		for command in &block.commands {
181			let mut command_output = String::new();
182
183			// Call the start_command() hook.
184			command_output.push_str(&ensure_eol(
185				runner.start_command(command).map_err(|e| {
186					io::Error::new(
187						ErrorKind::Other,
188						format!("start_command failed at line {}: {e}", command.line_number),
189					)
190				})?,
191				eol,
192			));
193
194			// Execute the command. Handle panics and errors if
195			// requested. We assume the command is unwind-safe
196			// when handling panics, it is up to callers to
197			// manage this appropriately.
198			let run = AssertUnwindSafe(|| runner.run(command));
199			command_output.push_str(&match panic::catch_unwind(run) {
200				// Unexpected success, error out.
201				Ok(Ok(output)) if command.fail => {
202					return Err(io::Error::new(
203						ErrorKind::Other,
204						format!(
205							"expected command '{}' to fail at line {}, succeeded with: {output}",
206							command.name, command.line_number
207						),
208					));
209				}
210
211				// Expected success, output the result.
212				Ok(Ok(output)) => output,
213
214				// Expected error, output it.
215				Ok(Err(e)) if command.fail => {
216					format!("{e}")
217				}
218
219				// Unexpected error, return it.
220				Ok(Err(e)) => {
221					return Err(io::Error::new(
222						ErrorKind::Other,
223						format!(
224							"command '{}' failed at line {}: {e}",
225							command.name, command.line_number
226						),
227					));
228				}
229
230				// Expected panic, output it.
231				Err(panic) if command.fail => {
232					let message = panic
233						.downcast_ref::<&str>()
234						.map(|s| s.to_string())
235						.or_else(|| panic.downcast_ref::<String>().cloned())
236						.unwrap_or_else(|| panic::resume_unwind(panic));
237					format!("Panic: {message}")
238				}
239
240				// Unexpected panic, throw it.
241				Err(panic) => panic::resume_unwind(panic),
242			});
243
244			// Make sure the command output has a trailing newline,
245			// unless empty.
246			command_output = ensure_eol(command_output, eol);
247
248			// Call the end_command() hook.
249			command_output.push_str(&ensure_eol(
250				runner.end_command(command).map_err(|e| {
251					io::Error::new(
252						ErrorKind::Other,
253						format!("end_command failed at line {}: {e}", command.line_number),
254					)
255				})?,
256				eol,
257			));
258
259			// Silence the output if requested.
260			if command.silent {
261				command_output = "".to_string();
262			}
263
264			// Prefix output lines if requested.
265			if let Some(prefix) = &command.prefix {
266				if !command_output.is_empty() {
267					command_output = format!(
268						"{prefix}: {}{eol}",
269						command_output
270							.strip_suffix(eol)
271							.unwrap_or(command_output.as_str())
272							.replace('\n', &format!("\n{prefix}: "))
273					);
274				}
275			}
276
277			block_output.push_str(&command_output);
278		}
279
280		// Call the end_block() hook.
281		block_output.push_str(&ensure_eol(
282			runner.end_block().map_err(|e| {
283				io::Error::new(
284					ErrorKind::Other,
285					format!("end_block failed at line {}: {e}", block.line_number),
286				)
287			})?,
288			eol,
289		));
290
291		// If the block doesn't have any output, default to "ok".
292		if block_output.is_empty() {
293			block_output.push_str("ok\n")
294		}
295
296		// If the block output contains blank lines, use a > prefix for
297		// it.
298		//
299		// We'd be better off using regular expressions here, but don't
300		// want to add a dependency just for this.
301		if block_output.starts_with('\n')
302			|| block_output.starts_with("\r\n")
303			|| block_output.contains("\n\n")
304			|| block_output.contains("\n\r\n")
305		{
306			block_output = format!("> {}", block_output.replace('\n', "\n> "));
307			// Remove trailing space from blank lines ("> \n" -> ">\n")
308			block_output = block_output.replace("> \n", ">\n");
309			// We guarantee above that block output ends with a
310			// newline, so we remove the "> " at the end of the
311			// output.
312			block_output.pop();
313			block_output.pop();
314		}
315
316		// Add the resulting block to the output. If this is not the
317		// last block, also add a newline separator.
318		output.push_str(&format!("{}---{eol}{}", block.literal, block_output));
319		if i < blocks.len() - 1 {
320			output.push_str(eol);
321		}
322	}
323
324	// Call the end_script() hook.
325	runner.end_script().map_err(|e| io::Error::new(ErrorKind::Other, format!("end_script failed: {e}")))?;
326
327	Ok(output)
328}
329
330/// Appends a newline if the string is not empty and doesn't already have one.
331fn ensure_eol(mut s: String, eol: &str) -> String {
332	if let Some(c) = s.chars().next_back() {
333		if c != '\n' {
334			s.push_str(eol)
335		}
336	}
337	s
338}
339
340// NB: most tests are done as testscripts under tests/.
341#[cfg(test)]
342pub mod tests {
343	use super::*;
344
345	/// A runner which simply counts the number of times its hooks are
346	/// called.
347	#[derive(Default)]
348	struct HookRunner {
349		start_script_count: usize,
350		end_script_count: usize,
351		start_block_count: usize,
352		end_block_count: usize,
353		start_command_count: usize,
354		end_command_count: usize,
355	}
356
357	impl Runner for HookRunner {
358		fn run(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
359			Ok(String::new())
360		}
361
362		fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
363			self.start_script_count += 1;
364			Ok(())
365		}
366
367		fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
368			self.end_script_count += 1;
369			Ok(())
370		}
371
372		fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
373			self.start_block_count += 1;
374			Ok(String::new())
375		}
376
377		fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
378			self.end_block_count += 1;
379			Ok(String::new())
380		}
381
382		fn start_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
383			self.start_command_count += 1;
384			Ok(String::new())
385		}
386
387		fn end_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
388			self.end_command_count += 1;
389			Ok(String::new())
390		}
391	}
392
393	/// Tests that runner hooks are called as expected.
394	#[test]
395	fn hooks() {
396		let mut runner = HookRunner::default();
397		generate(
398			&mut runner,
399			r#"
400command
401---
402
403command
404command
405---
406"#,
407		)
408		.unwrap();
409
410		assert_eq!(runner.start_script_count, 1);
411		assert_eq!(runner.end_script_count, 1);
412		assert_eq!(runner.start_block_count, 2);
413		assert_eq!(runner.end_block_count, 2);
414		assert_eq!(runner.start_command_count, 3);
415		assert_eq!(runner.end_command_count, 3);
416	}
417}