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::{command::Command, parser::parse},
23};
24
25pub trait Runner {
27 fn run(&mut self, command: &Command) -> Result<String, Box<dyn Error>>;
38
39 fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
43 Ok(())
44 }
45
46 fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
50 Ok(())
51 }
52
53 fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
56 Ok(String::new())
57 }
58
59 fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
62 Ok(String::new())
63 }
64
65 #[allow(unused_variables)]
69 fn start_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
70 Ok(String::new())
71 }
72
73 #[allow(unused_variables)]
77 fn end_command(&mut self, command: &Command) -> Result<String, Box<dyn Error>> {
78 Ok(String::new())
79 }
80}
81
82pub fn run_path<R: Runner, P: AsRef<Path>>(runner: &mut R, path: P) -> io::Result<()> {
89 let path = path.as_ref();
90 let Some(dir) = path.parent() else {
91 return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
92 };
93 let Some(filename) = path.file_name() else {
94 return Err(io::Error::new(ErrorKind::InvalidInput, format!("invalid path '{path:?}'")));
95 };
96
97 if filename.to_str().unwrap().ends_with(".skip") {
98 return Ok(());
99 }
100
101 let input = read_to_string(dir.join(filename))?;
102 let output = generate(runner, &input)?;
103
104 Mint::new(dir).new_goldenfile(filename)?.write_all(output.as_bytes())
105}
106
107pub fn run<R: Runner, S: Into<String>>(runner: R, test: S) {
108 try_run(runner, test).unwrap();
109}
110
111pub fn try_run<R: Runner, S: Into<String>>(mut runner: R, test: S) -> io::Result<()> {
112 let input = test.into();
113
114 let dir = temp_dir();
115 #[allow(clippy::disallowed_methods)]
116 let file_name = format!(
117 "test-{}-{}.txt",
118 process::id(),
119 SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos()
120 );
121 let file_path = dir.join(&file_name);
122
123 let mut file = fs::File::create(&file_path)?;
124 file.write_all(input.as_bytes())?;
125
126 let output = generate(&mut runner, &input)?;
127 Mint::new(dir).new_goldenfile(&file_name)?.write_all(output.as_bytes())
128}
129
130pub fn generate<R: Runner>(runner: &mut R, input: &str) -> io::Result<String> {
132 let mut output = String::with_capacity(input.len()); let eol = match input.find("\r\n") {
136 Some(_) => "\r\n",
137 None => "\n",
138 };
139
140 let blocks = parse(input).map_err(|e| {
142 io::Error::new(
143 ErrorKind::InvalidInput,
144 format!(
145 "parse error at line {} column {} for {:?}:\n{}\n{}^",
146 e.input.location_line(),
147 e.input.get_column(),
148 e.code,
149 String::from_utf8_lossy(e.input.get_line_beginning()),
150 ' '.to_string().repeat(e.input.get_utf8_column() - 1)
151 ),
152 )
153 })?;
154
155 runner.start_script().map_err(|e| io::Error::other(format!("start_script failed: {e}")))?;
157
158 for (i, block) in blocks.iter().enumerate() {
159 if block.commands.is_empty() {
163 output.push_str(&block.literal);
164 continue;
165 }
166
167 let mut block_output = String::new();
169
170 block_output.push_str(&ensure_eol(
172 runner.start_block().map_err(|e| {
173 io::Error::other(format!("start_block failed at line {}: {e}", block.line_number))
174 })?,
175 eol,
176 ));
177
178 for command in &block.commands {
179 let mut command_output = String::new();
180
181 command_output.push_str(&ensure_eol(
183 runner.start_command(command).map_err(|e| {
184 io::Error::other(format!(
185 "start_command failed at line {}: {e}",
186 command.line_number
187 ))
188 })?,
189 eol,
190 ));
191
192 let run = AssertUnwindSafe(|| runner.run(command));
197 command_output.push_str(&match panic::catch_unwind(run) {
198 Ok(Ok(output)) if command.fail => {
200 return Err(io::Error::other(format!(
201 "expected command '{}' to fail at line {}, succeeded with: {output}",
202 command.name, command.line_number
203 )));
204 }
205
206 Ok(Ok(output)) => output,
208
209 Ok(Err(e)) if command.fail => {
211 format!("{e}")
212 }
213
214 Ok(Err(e)) => {
216 return Err(io::Error::other(format!(
217 "command '{}' failed at line {}: {e}",
218 command.name, command.line_number
219 )));
220 }
221
222 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(|| panic::resume_unwind(panic));
229 format!("Panic: {message}")
230 }
231
232 Err(panic) => panic::resume_unwind(panic),
234 });
235
236 command_output = ensure_eol(command_output, eol);
239
240 command_output.push_str(&ensure_eol(
242 runner.end_command(command).map_err(|e| {
243 io::Error::other(format!(
244 "end_command failed at line {}: {e}",
245 command.line_number
246 ))
247 })?,
248 eol,
249 ));
250
251 if command.silent {
253 command_output = "".to_string();
254 }
255
256 if let Some(prefix) = &command.prefix
258 && !command_output.is_empty()
259 {
260 command_output = format!(
261 "{prefix}: {}{eol}",
262 command_output
263 .strip_suffix(eol)
264 .unwrap_or(command_output.as_str())
265 .replace('\n', &format!("\n{prefix}: "))
266 );
267 }
268
269 block_output.push_str(&command_output);
270 }
271
272 block_output.push_str(&ensure_eol(
274 runner.end_block().map_err(|e| {
275 io::Error::other(format!("end_block failed at line {}: {e}", block.line_number))
276 })?,
277 eol,
278 ));
279
280 if block_output.is_empty() {
282 block_output.push_str("ok\n")
283 }
284
285 if block_output.starts_with('\n')
291 || block_output.starts_with("\r\n")
292 || block_output.contains("\n\n")
293 || block_output.contains("\n\r\n")
294 {
295 block_output = format!("> {}", block_output.replace('\n', "\n> "));
296 block_output = block_output.replace("> \n", ">\n");
298 block_output.pop();
302 block_output.pop();
303 }
304
305 output.push_str(&format!("{}---{eol}{}", block.literal, block_output));
308 if i < blocks.len() - 1 {
309 output.push_str(eol);
310 }
311 }
312
313 runner.end_script().map_err(|e| io::Error::other(format!("end_script failed: {e}")))?;
315
316 Ok(output)
317}
318
319fn ensure_eol(mut s: String, eol: &str) -> String {
321 if let Some(c) = s.chars().next_back()
322 && c != '\n'
323 {
324 s.push_str(eol)
325 }
326 s
327}
328
329#[cfg(test)]
331pub mod tests {
332 use super::*;
333
334 #[derive(Default)]
337 struct HookRunner {
338 start_script_count: usize,
339 end_script_count: usize,
340 start_block_count: usize,
341 end_block_count: usize,
342 start_command_count: usize,
343 end_command_count: usize,
344 }
345
346 impl Runner for HookRunner {
347 fn run(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
348 Ok(String::new())
349 }
350
351 fn start_script(&mut self) -> Result<(), Box<dyn Error>> {
352 self.start_script_count += 1;
353 Ok(())
354 }
355
356 fn end_script(&mut self) -> Result<(), Box<dyn Error>> {
357 self.end_script_count += 1;
358 Ok(())
359 }
360
361 fn start_block(&mut self) -> Result<String, Box<dyn Error>> {
362 self.start_block_count += 1;
363 Ok(String::new())
364 }
365
366 fn end_block(&mut self) -> Result<String, Box<dyn Error>> {
367 self.end_block_count += 1;
368 Ok(String::new())
369 }
370
371 fn start_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
372 self.start_command_count += 1;
373 Ok(String::new())
374 }
375
376 fn end_command(&mut self, _: &Command) -> Result<String, Box<dyn Error>> {
377 self.end_command_count += 1;
378 Ok(String::new())
379 }
380 }
381
382 #[test]
384 fn hooks() {
385 let mut runner = HookRunner::default();
386 generate(
387 &mut runner,
388 r#"
389command
390---
391
392command
393command
394---
395"#,
396 )
397 .unwrap();
398
399 assert_eq!(runner.start_script_count, 1);
400 assert_eq!(runner.end_script_count, 1);
401 assert_eq!(runner.start_block_count, 2);
402 assert_eq!(runner.end_block_count, 2);
403 assert_eq!(runner.start_command_count, 3);
404 assert_eq!(runner.end_command_count, 3);
405 }
406}