md_cli_test/
case.rs

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        // Split into commands and expected output
75        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        // Remove trailing newline
133        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        // On macOS, temporary directories may appear with a `/private` prefix,
252        // e.g., `/private/var/folders/...`, which causes mismatch with expected output
253        // defined as `/var/folders/...`. To ensure cross-platform consistency,
254        // we normalize such paths in test output comparison.
255        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}