1use std::ffi::OsString;
2use std::ops::{Deref, DerefMut};
3use std::path::{Path, PathBuf};
4use std::{env, fs, io, mem};
5
6use assert_cmd::Command;
7use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
8
9use super::cmd::{Cmd, CmdResponse};
10use crate::error::{self, TestError};
11
12pub struct TestSection {
13 pub title: String,
14 pub cases: Vec<TestCase>,
15}
16
17#[derive(Debug, Default)]
18pub struct TestCase {
19 pub commands: Vec<String>,
20 pub cargo_bin_alias: String,
21 pub cargo_bin_name: Option<String>,
22 pub test_dir: Option<PathBuf>,
23 pub output: ExpectedOutput,
24 pub envs: Vec<(OsString, OsString)>,
25}
26
27#[derive(Debug, Default)]
28pub struct ExpectedOutput {
29 pub text: String,
30 pub source_path: Option<PathBuf>,
31 pub source_line: Option<usize>,
32}
33
34enum Multiline {
35 ToEndString(&'static str, String),
36 WithLinesHasEnd(&'static str, String),
37}
38
39impl Deref for Multiline {
40 type Target = String;
41
42 fn deref(&self) -> &Self::Target {
43 match self {
44 Self::ToEndString(_, string) => string,
45 Self::WithLinesHasEnd(_, string) => string,
46 }
47 }
48}
49
50impl DerefMut for Multiline {
51 fn deref_mut(&mut self) -> &mut Self::Target {
52 match self {
53 Self::ToEndString(_, string) => string,
54 Self::WithLinesHasEnd(_, string) => string,
55 }
56 }
57}
58
59impl From<Multiline> for String {
60 fn from(value: Multiline) -> Self {
61 match value {
62 Multiline::ToEndString(_, string) => string,
63 Multiline::WithLinesHasEnd(_, string) => string,
64 }
65 }
66}
67
68impl TestCase {
69 pub fn parse(source: impl AsRef<str>, source_path: Option<PathBuf>, source_line: Option<usize>) -> Self {
70 let mut commands = Vec::new();
71 let mut expected_output = String::new();
72 let mut multiline_command: Option<Multiline> = None;
73
74 for mut line in source.as_ref().lines() {
76 if let Some(mut command) = multiline_command.take() {
77 command.push('\n');
78
79 let is_last_line = match &command {
80 Multiline::ToEndString(end, _) => line.contains(*end),
81 Multiline::WithLinesHasEnd(end, _) => {
82 if line.ends_with(*end) {
83 if line.len() > 1 {
84 line = &line[..line.len() - 1];
85 } else {
86 line = "";
87 }
88 false
89 } else {
90 true
91 }
92 },
93 };
94
95 command.push_str(line);
96 if is_last_line {
97 commands.push(command.into());
98 } else {
99 multiline_command = Some(command);
100 }
101 continue;
102 }
103
104 if line.starts_with("$") {
105 let mut line = line.trim_start_matches('$').trim_start().to_string();
106
107 let open_string_idx = line.rfind("#\"");
108 let close_string_idx = line.rfind("\"#");
109 if open_string_idx.is_some() && open_string_idx.map(|idx| idx + 1) >= close_string_idx {
110 multiline_command = Some(Multiline::ToEndString("\"#", line));
111 } else {
112 let mark_string_count = line.matches("\"").count();
113 if mark_string_count % 2 == 1 {
114 multiline_command = Some(Multiline::ToEndString("\"", line));
115 } else if line.ends_with('\\') {
116 line.pop();
117 multiline_command = Some(Multiline::WithLinesHasEnd("\\", line));
118 } else {
119 commands.push(line);
120 }
121 }
122 } else if !commands.is_empty() {
123 expected_output.push_str(line);
124 expected_output.push('\n');
125 }
126 }
127
128 if let Some(command) = multiline_command {
129 commands.push(command.into());
130 }
131
132 if !source.as_ref().ends_with('\n') && expected_output.ends_with('\n') {
134 expected_output.pop();
135 }
136
137 Self {
138 commands,
139 cargo_bin_alias: String::new(),
140 cargo_bin_name: None,
141 test_dir: None,
142 output: ExpectedOutput {
143 text: expected_output,
144 source_path,
145 source_line,
146 },
147 envs: Vec::new(),
148 }
149 }
150
151 pub fn with_cargo_bin_alias(mut self, alias: impl Into<String>, cargo_bin_name: Option<impl Into<String>>) -> Self {
152 self.set_cargo_bin_alias(alias, cargo_bin_name);
153 self
154 }
155
156 pub fn set_cargo_bin_alias(&mut self, alias: impl Into<String>, cargo_bin_name: Option<impl Into<String>>) {
157 self.cargo_bin_alias = alias.into();
158 self.cargo_bin_name = cargo_bin_name.map(Into::into);
159 }
160
161 pub fn with_test_dir(mut self, test_dir: impl Into<PathBuf>) -> Self {
162 self.test_dir = Some(test_dir.into());
163 self
164 }
165
166 pub fn with_env(mut self, key: impl Into<OsString>, val: impl Into<OsString>) -> Self {
167 self.envs.push((key.into(), val.into()));
168 self
169 }
170
171 pub fn with_envs(mut self, vars: impl IntoIterator<Item = (impl Into<OsString>, impl Into<OsString>)>) -> Self {
172 self.push_envs(vars);
173 self
174 }
175
176 pub fn push_envs(&mut self, vars: impl IntoIterator<Item = (impl Into<OsString>, impl Into<OsString>)>) {
177 for (key, val) in vars {
178 self.envs.push((key.into(), val.into()));
179 }
180 }
181
182 pub fn run(&self) -> error::Result<()> {
183 let mut root_dir = self.test_dir.clone().unwrap_or_default();
184 if !root_dir.exists() {
185 return Err(TestError::Failed(format!(
186 "Root directory `{}` does not exist",
187 root_dir.display()
188 )));
189 }
190
191 for command in &self.commands {
192 match Cmd::parse(&root_dir, command) {
193 Ok(cmd) => match cmd.run()? {
194 CmdResponse::Success => (),
195 CmdResponse::ChangeDirTo(path) => root_dir = path,
196 CmdResponse::Output(output) => self.assert_command_output(&root_dir, command, output),
197 },
198 Err(parts) => {
199 if let [name, args @ ..] = &parts[..] {
200 let mut cmd = if *name == self.cargo_bin_alias {
201 let bin_name = if let Some(bin_name) = &self.cargo_bin_name {
202 bin_name.clone()
203 } else {
204 env::var("CARGO_PKG_NAME")?
205 };
206
207 Command::cargo_bin(bin_name)?
208 } else {
209 Command::cargo_bin(name)?
210 };
211
212 let cmd_assert = cmd
213 .envs(self.envs.iter().map(|(key, val)| (key, val)))
214 .args(args)
215 .current_dir(&root_dir)
216 .assert();
217
218 let stdout = separate_logs(&String::from_utf8_lossy(&cmd_assert.get_output().stdout));
219 let stderr = separate_logs(&String::from_utf8_lossy(&cmd_assert.get_output().stderr));
220 let full_output = format!("{stdout}{stderr}");
221
222 self.assert_command_output(&root_dir, command, full_output);
223 } else {
224 return Err(TestError::Failed(format!("Invalid command `{command}`")));
225 }
226 },
227 }
228 }
229
230 Ok(())
231 }
232
233 pub fn assert_command_output(&self, root_dir: impl AsRef<Path>, command: impl AsRef<str>, output: impl AsRef<str>) {
234 let root_dir = root_dir.as_ref();
235 let command = command.as_ref();
236 let output = output.as_ref();
237
238 let expected_output = self
239 .output
240 .text
241 .replace("${current_dir_path}", &root_dir.to_string_lossy());
242
243 let source_path = self
244 .output
245 .source_path
246 .as_ref()
247 .map(|path| path.display().to_string())
248 .unwrap_or_default();
249 let source_line = self.output.source_line.unwrap_or_default();
250
251 let normalized_output = output.replace("/private/var/", "/var/");
256
257 assert_eq!(
258 normalized_output, expected_output,
259 "Command `{command}` in source {source_path}:{source_line}"
260 );
261 }
262}
263
264pub fn parse_markdown_tests(
265 md_file_path: impl AsRef<Path>,
266 cargo_bin_alias: Option<String>,
267 cargo_bin_name: Option<String>,
268 vars: Option<impl IntoIterator<Item = (impl Into<OsString>, impl Into<OsString>)> + Clone>,
269) -> io::Result<Vec<TestSection>> {
270 let md_file_path = md_file_path.as_ref();
271 let content = fs::read_to_string(md_file_path)?;
272 let parser = Parser::new(&content);
273
274 let mut sections = Vec::new();
275 let mut cases = Vec::new();
276 let mut test_case = None;
277 let mut test_case_start_line = None;
278 let mut section_title = String::new();
279 let mut in_test_case_code_block = false;
280 let mut in_section_heading = false;
281
282 for (event, range) in parser.into_offset_iter() {
283 match event {
284 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
285 if lang.as_ref() == "sh" || lang.as_ref() == "shell" =>
286 {
287 in_test_case_code_block = true;
288 test_case_start_line = Some(content.split_at(range.start).0.lines().count() + 1);
289 },
290 Event::Text(text) if in_test_case_code_block => {
291 let mut new_test_case = TestCase::parse(text, Some(md_file_path.into()), test_case_start_line);
292 if let Some(alias) = cargo_bin_alias.clone() {
293 new_test_case.set_cargo_bin_alias(alias, cargo_bin_name.clone());
294 }
295 if let Some(vars) = vars.clone() {
296 new_test_case.push_envs(vars);
297 }
298
299 test_case = Some(new_test_case);
300 },
301 Event::End(TagEnd::CodeBlock) if in_test_case_code_block => {
302 if let Some(test) = test_case.take() {
303 cases.push(test);
304 }
305 in_test_case_code_block = false;
306 },
307 Event::Start(Tag::Heading {
308 level: HeadingLevel::H1,
309 ..
310 }) => {
311 if !cases.is_empty() {
312 sections.push(TestSection {
313 title: mem::take(&mut section_title),
314 cases,
315 });
316 cases = Vec::new();
317 }
318 in_section_heading = true;
319 },
320 Event::Text(text) if in_section_heading => {
321 section_title = text.to_string();
322 },
323 Event::End(TagEnd::Heading(HeadingLevel::H1)) if in_section_heading => {
324 in_section_heading = false;
325 },
326 _ => {},
327 }
328 }
329
330 if !cases.is_empty() {
331 sections.push(TestSection {
332 title: section_title,
333 cases,
334 });
335 }
336
337 Ok(sections)
338}
339
340fn separate_logs(source: &str) -> String {
341 let mut outputs = source
342 .lines()
343 .filter(|line| {
344 if line.trim().starts_with("[log]") {
345 log::debug!("{line}");
346 false
347 } else {
348 true
349 }
350 })
351 .collect::<Vec<_>>();
352
353 if source.ends_with('\n') {
354 outputs.push("");
355 }
356
357 outputs.join("\n")
358}