Skip to main content

reifydb_testing/testscript/
runner.rs

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