reifydb_testing/testscript/
runner.rs1use 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 {
30 fn run(&mut self, command: &Command) -> Result<String, Box<dyn Error>>;
41
42 fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
46 Ok(())
47 }
48
49 fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
53 Ok(())
54 }
55
56 fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
59 Ok(String::new())
60 }
61
62 fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
65 Ok(String::new())
66 }
67
68 #[allow(unused_variables)]
72 fn start_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
73 Ok(String::new())
74 }
75
76 #[allow(unused_variables)]
80 fn end_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
81 Ok(String::new())
82 }
83}
84
85pub 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
133pub fn generate<R: Runner>(runner: &mut R, input: &str) -> io::Result<String> {
135 let mut output = String::with_capacity(input.len()); 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
280fn 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#[cfg(test)]
292pub mod tests {
293 use super::*;
294
295 #[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 #[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}