Skip to main content

reifydb_testing/testscript/
runner.rs

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