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