Skip to main content

reifydb_testing/testscript/
runner.rs

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