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::{
23		command::{Block, Command},
24		parser::parse,
25	},
26};
27
28/// Runs testscript commands, returning their output.
29pub trait Runner {
30	/// Runs a testscript command, returning its output, or an error if the
31	/// command fails.
32	///
33	/// Arguments can be accessed directly via [`Command::args`], or by
34	/// using the [`Command::consume_args`] helper for more convenient
35	/// processing.
36	///
37	/// Error cases are typically tested by running the command with a `!`
38	/// prefix (expecting a failure), but the runner can also handle these
39	/// itself and return an `Ok` result with appropriate output.
40	fn run(&mut self, command: &Command) -> Result<String, Box<dyn Error>>;
41
42	/// Called at the start of a testscript. Used e.g. for initial setup.
43	/// Can't return output, since it's not called in the context of a
44	/// block.
45	fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
46		Ok(())
47	}
48
49	/// Called at the end of a testscript. Used e.g. for state assertions.
50	/// Can't return output, since it's not called in the context of a
51	/// block.
52	fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
53		Ok(())
54	}
55
56	/// Called at the start of a block. Used e.g. to output initial state.
57	/// Any output is prepended to the block's output.
58	fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
59		Ok(String::new())
60	}
61
62	/// Called at the end of a block. Used e.g. to output final state.
63	/// Any output is appended to the block's output.
64	fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
65		Ok(String::new())
66	}
67
68	/// Called at the start of a command. Used e.g. for setup. Any output is
69	/// prepended to the command's output, and is affected e.g. by the
70	/// prefix and silencing of the command.
71	#[allow(unused_variables)]
72	fn start_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
73		Ok(String::new())
74	}
75
76	/// Called at the end of a command. Used e.g. for cleanup. Any output is
77	/// appended to the command's output, and is affected e.g. by the prefix
78	/// and silencing of the command.
79	#[allow(unused_variables)]
80	fn end_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
81		Ok(String::new())
82	}
83}
84
85/// Runs a testscript at the given path.
86///
87/// Panics if the script output differs from the current input file. Errors on
88/// IO, parser, or runner failure. If the environment variable
89/// `UPDATE_TESTFILES=1` is set, the new output file will replace the input
90/// file.
91pub fn run_path<R: Runner, P: AsRef<Path>>(runner: &mut R, path: P) -> io::Result<()> {
92	let path = path.as_ref();
93	let Some(dir) = path.parent() else {
94		return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
95	};
96	let Some(filename) = path.file_name() else {
97		return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
98	};
99
100	if filename.to_str().unwrap().ends_with(".skip") {
101		return Ok(());
102	}
103
104	let input = read_to_string(dir.join(filename))?;
105	let output = generate(runner, &input)?;
106
107	Mint::new(dir).new_goldenfile(filename)?.write_all(output.as_bytes())
108}
109
110pub fn run<R: Runner, S: Into<String>>(runner: R, test: S) {
111	try_run(runner, test).unwrap();
112}
113
114pub fn try_run<R: Runner, S: Into<String>>(mut runner: R, test: S) -> io::Result<()> {
115	let input = test.into();
116
117	let dir = temp_dir();
118	#[allow(clippy::disallowed_methods)]
119	let file_name = format!(
120		"test-{}-{}.txt",
121		process::id(),
122		SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos()
123	);
124	let file_path = dir.join(&file_name);
125
126	let mut file = fs::File::create(&file_path)?;
127	file.write_all(input.as_bytes())?;
128
129	let output = generate(&mut runner, &input)?;
130	Mint::new(dir).new_goldenfile(&file_name)?.write_all(output.as_bytes())
131}
132
133/// Generates output for a testscript input, without comparing them.
134pub fn generate<R: Runner>(runner: &mut R, input: &str) -> io::Result<String> {
135	let mut output = String::with_capacity(input.len()); // common case: output == input
136	let eol = detect_eol(input);
137	let blocks = parse_blocks(input)?;
138
139	runner.start_script().map_err(|e| io::Error::other(format!("start_script failed: {e}")))?;
140
141	for (i, block) in blocks.iter().enumerate() {
142		if block.commands.is_empty() {
143			output.push_str(&block.literal);
144			continue;
145		}
146		let block_output = process_block(runner, block, eol)?;
147		output.push_str(&format!("{}---{eol}{}", block.literal, block_output));
148		if i < blocks.len() - 1 {
149			output.push_str(eol);
150		}
151	}
152
153	runner.end_script().map_err(|e| io::Error::other(format!("end_script failed: {e}")))?;
154	Ok(output)
155}
156
157#[inline]
158fn detect_eol(input: &str) -> &'static str {
159	if input.contains("\r\n") {
160		"\r\n"
161	} else {
162		"\n"
163	}
164}
165
166#[inline]
167fn parse_blocks(input: &str) -> io::Result<Vec<Block>> {
168	parse(input).map_err(|e| {
169		io::Error::new(
170			ErrorKind::InvalidInput,
171			format!(
172				"parse error at line {} column {} for {:?}:\n{}\n{}^",
173				e.input.location_line(),
174				e.input.get_column(),
175				e.code,
176				String::from_utf8_lossy(e.input.get_line_beginning()),
177				' '.to_string().repeat(e.input.get_utf8_column() - 1)
178			),
179		)
180	})
181}
182
183fn process_block<R: Runner>(runner: &mut R, block: &Block, eol: &str) -> io::Result<String> {
184	let mut block_output = String::new();
185	block_output.push_str(&ensure_eol(
186		runner.start_block().map_err(|e| {
187			io::Error::other(format!("start_block failed at line {}: {e}", block.line_number))
188		})?,
189		eol,
190	));
191	for command in &block.commands {
192		let command_output = process_command(runner, command, eol)?;
193		block_output.push_str(&command_output);
194	}
195	block_output.push_str(&ensure_eol(
196		runner.end_block().map_err(|e| {
197			io::Error::other(format!("end_block failed at line {}: {e}", block.line_number))
198		})?,
199		eol,
200	));
201	if block_output.is_empty() {
202		block_output.push_str("ok\n");
203	}
204	Ok(apply_blank_line_prefix(block_output))
205}
206
207fn process_command<R: Runner>(runner: &mut R, command: &Command, eol: &str) -> io::Result<String> {
208	let mut command_output = String::new();
209	command_output.push_str(&ensure_eol(
210		runner.start_command(command).map_err(|e| {
211			io::Error::other(format!("start_command failed at line {}: {e}", command.line_number))
212		})?,
213		eol,
214	));
215	command_output.push_str(&run_command_with_panic_handling(runner, command)?);
216	command_output = ensure_eol(command_output, eol);
217	command_output.push_str(&ensure_eol(
218		runner.end_command(command).map_err(|e| {
219			io::Error::other(format!("end_command failed at line {}: {e}", command.line_number))
220		})?,
221		eol,
222	));
223	if command.silent {
224		command_output.clear();
225	}
226	if let Some(prefix) = &command.prefix
227		&& !command_output.is_empty()
228	{
229		command_output = format!(
230			"{prefix}: {}{eol}",
231			command_output
232				.strip_suffix(eol)
233				.unwrap_or(command_output.as_str())
234				.replace('\n', &format!("\n{prefix}: "))
235		);
236	}
237	Ok(command_output)
238}
239
240fn run_command_with_panic_handling<R: Runner>(runner: &mut R, command: &Command) -> io::Result<String> {
241	let run = AssertUnwindSafe(|| runner.run(command));
242	match panic::catch_unwind(run) {
243		Ok(Ok(output)) if command.fail => Err(io::Error::other(format!(
244			"expected command '{}' to fail at line {}, succeeded with: {output}",
245			command.name, command.line_number
246		))),
247		Ok(Ok(output)) => Ok(output),
248		Ok(Err(e)) if command.fail => Ok(format!("{e}")),
249		Ok(Err(e)) => Err(io::Error::other(format!(
250			"command '{}' failed at line {}: {e}",
251			command.name, command.line_number
252		))),
253		Err(panic) if command.fail => {
254			let message = panic
255				.downcast_ref::<&str>()
256				.map(|s| s.to_string())
257				.or_else(|| panic.downcast_ref::<String>().cloned())
258				.unwrap_or_else(|| panic::resume_unwind(panic));
259			Ok(format!("Panic: {message}"))
260		}
261		Err(panic) => panic::resume_unwind(panic),
262	}
263}
264
265#[inline]
266fn apply_blank_line_prefix(mut block_output: String) -> String {
267	if block_output.starts_with('\n')
268		|| block_output.starts_with("\r\n")
269		|| block_output.contains("\n\n")
270		|| block_output.contains("\n\r\n")
271	{
272		block_output = format!("> {}", block_output.replace('\n', "\n> "));
273		block_output = block_output.replace("> \n", ">\n");
274		block_output.pop();
275		block_output.pop();
276	}
277	block_output
278}
279
280/// Appends a newline if the string is not empty and doesn't already have one.
281fn ensure_eol(mut s: String, eol: &str) -> String {
282	if let Some(c) = s.chars().next_back()
283		&& c != '\n'
284	{
285		s.push_str(eol)
286	}
287	s
288}
289
290// NB: most tests are done as testscripts under tests/.
291#[cfg(test)]
292pub mod tests {
293	use super::*;
294
295	/// A runner which simply counts the number of times its hooks are
296	/// called.
297	#[derive(Default)]
298	struct HookRunner {
299		start_script_count: usize,
300		end_script_count: usize,
301		start_block_count: usize,
302		end_block_count: usize,
303		start_command_count: usize,
304		end_command_count: usize,
305	}
306
307	impl Runner for HookRunner {
308		fn run(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
309			Ok(String::new())
310		}
311
312		fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
313			self.start_script_count += 1;
314			Ok(())
315		}
316
317		fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
318			self.end_script_count += 1;
319			Ok(())
320		}
321
322		fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
323			self.start_block_count += 1;
324			Ok(String::new())
325		}
326
327		fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
328			self.end_block_count += 1;
329			Ok(String::new())
330		}
331
332		fn start_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
333			self.start_command_count += 1;
334			Ok(String::new())
335		}
336
337		fn end_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
338			self.end_command_count += 1;
339			Ok(String::new())
340		}
341	}
342
343	/// Tests that runner hooks are called as expected.
344	#[test]
345	fn hooks() {
346		let mut runner = HookRunner::default();
347		generate(
348			&mut runner,
349			r#"
350command
351---
352
353command
354command
355---
356"#,
357		)
358		.unwrap();
359
360		assert_eq!(runner.start_script_count, 1);
361		assert_eq!(runner.end_script_count, 1);
362		assert_eq!(runner.start_block_count, 2);
363		assert_eq!(runner.end_block_count, 2);
364		assert_eq!(runner.start_command_count, 3);
365		assert_eq!(runner.end_command_count, 3);
366	}
367}