reifydb_testing/testscript/
runner.rs1use 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 #[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 #[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}