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