trycmd_indygreg_fork/
schema.rs

1//! `cmd.toml` Schema
2//!
3//! [`OneShot`] is the top-level item in the `cmd.toml` files.
4
5use snapbox::{NormalizeNewlines, NormalizePaths};
6use std::collections::BTreeMap;
7use std::collections::VecDeque;
8
9/// A function that turns a filesystem path into a [TryCmd].
10pub type TryCmdLoader = fn(&std::path::Path) -> Result<TryCmd, crate::Error>;
11
12/// Mapping of file extension to function that can load it.
13#[derive(Clone)]
14pub struct TryCmdLoaders(BTreeMap<std::ffi::OsString, TryCmdLoader>);
15
16// Rust <1.70 cannot derive(Debug) for function pointers.
17// TODO remove once MSRV >= 1.70.
18impl std::fmt::Debug for TryCmdLoaders {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        let inner = f
21            .debug_map()
22            .entries(self.0.keys().map(|k| (k, "<function>")))
23            .finish()?;
24
25        f.debug_struct("TryCmdLoaders").field("0", &inner).finish()
26    }
27}
28
29impl Default for TryCmdLoaders {
30    fn default() -> Self {
31        let mut res = BTreeMap::new();
32
33        res.insert("toml".to_string().into(), TryCmd::load_toml as _);
34        res.insert("trycmd".to_string().into(), TryCmd::load_trycmd as _);
35        res.insert("md".to_string().into(), TryCmd::load_trycmd as _);
36
37        Self(res)
38    }
39}
40
41impl std::ops::Deref for TryCmdLoaders {
42    type Target =
43        BTreeMap<std::ffi::OsString, fn(&std::path::Path) -> Result<TryCmd, crate::Error>>;
44
45    fn deref(&self) -> &Self::Target {
46        &self.0
47    }
48}
49
50impl std::ops::DerefMut for TryCmdLoaders {
51    fn deref_mut(&mut self) -> &mut Self::Target {
52        &mut self.0
53    }
54}
55
56/// Represents an executable set of commands and their environment.
57#[derive(Clone, Default, Debug, PartialEq, Eq)]
58pub struct TryCmd {
59    pub steps: Vec<Step>,
60    pub fs: Filesystem,
61}
62
63impl TryCmd {
64    /// Construct an instance from a TOML file.
65    pub fn load_toml(path: &std::path::Path) -> Result<Self, crate::Error> {
66        let raw = std::fs::read_to_string(path)
67            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
68        let one_shot = OneShot::parse_toml(&raw)?;
69        let mut sequence: Self = one_shot.into();
70        let is_binary = match sequence.steps[0].binary {
71            true => snapbox::DataFormat::Binary,
72            false => snapbox::DataFormat::Text,
73        };
74
75        if sequence.steps[0].stdin.is_none() {
76            let stdin_path = path.with_extension("stdin");
77            let stdin = if stdin_path.exists() {
78                // No `map_text` as we will trust what the user inputted
79                Some(crate::Data::read_from(&stdin_path, Some(is_binary))?)
80            } else {
81                None
82            };
83            sequence.steps[0].stdin = stdin;
84        }
85
86        if sequence.steps[0].expected_stdout.is_none() {
87            let stdout_path = path.with_extension("stdout");
88            let stdout = if stdout_path.exists() {
89                Some(
90                    crate::Data::read_from(&stdout_path, Some(is_binary))?
91                        .normalize(NormalizePaths)
92                        .normalize(NormalizeNewlines),
93                )
94            } else {
95                None
96            };
97            sequence.steps[0].expected_stdout = stdout;
98        }
99
100        if sequence.steps[0].expected_stderr.is_none() {
101            let stderr_path = path.with_extension("stderr");
102            let stderr = if stderr_path.exists() {
103                Some(
104                    crate::Data::read_from(&stderr_path, Some(is_binary))?
105                        .normalize(NormalizePaths)
106                        .normalize(NormalizeNewlines),
107                )
108            } else {
109                None
110            };
111            sequence.steps[0].expected_stderr = stderr;
112        }
113
114        Ok(sequence)
115    }
116
117    /// Construct an instance from a .trycmd file.
118    pub fn load_trycmd(path: &std::path::Path) -> Result<Self, crate::Error> {
119        let raw = std::fs::read_to_string(path)
120            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
121        let normalized = snapbox::utils::normalize_lines(&raw);
122        Self::parse_trycmd(&normalized)
123    }
124
125    /// Construct an instance from a path, using the file extension to determine the type.
126    pub(crate) fn load(
127        loaders: &TryCmdLoaders,
128        path: &std::path::Path,
129    ) -> Result<Self, crate::Error> {
130        let mut sequence = if let Some(ext) = path.extension() {
131            let loader = loaders
132                .iter()
133                .find_map(|(x, loader)| if ext == x { Some(loader) } else { None });
134
135            if let Some(loader) = loader {
136                loader(path)?
137            } else {
138                return Err(format!("Unsupported extension: {}", ext.to_string_lossy()).into());
139            }
140        } else {
141            return Err("No extension".into());
142        };
143
144        sequence.fs.base = sequence.fs.base.take().map(|base| {
145            path.parent()
146                .unwrap_or_else(|| std::path::Path::new("."))
147                .join(base)
148        });
149        sequence.fs.cwd = sequence.fs.cwd.take().map(|cwd| {
150            path.parent()
151                .unwrap_or_else(|| std::path::Path::new("."))
152                .join(cwd)
153        });
154
155        if sequence.fs.base.is_none() {
156            let base_path = path.with_extension("in");
157            if base_path.exists() {
158                sequence.fs.base = Some(base_path);
159            } else if sequence.fs.cwd.is_some() {
160                sequence.fs.base = sequence.fs.cwd.clone();
161            }
162        }
163        if sequence.fs.cwd.is_none() {
164            sequence.fs.cwd = sequence.fs.base.clone();
165        }
166        if sequence.fs.sandbox.is_none() {
167            sequence.fs.sandbox = Some(path.with_extension("out").exists());
168        }
169
170        sequence.fs.base = sequence
171            .fs
172            .base
173            .take()
174            .map(|p| snapbox::path::resolve_dir(p).map_err(|e| e.to_string()))
175            .transpose()?;
176        sequence.fs.cwd = sequence
177            .fs
178            .cwd
179            .take()
180            .map(|p| snapbox::path::resolve_dir(p).map_err(|e| e.to_string()))
181            .transpose()?;
182
183        Ok(sequence)
184    }
185
186    pub(crate) fn overwrite(
187        &self,
188        path: &std::path::Path,
189        id: Option<&str>,
190        stdout: Option<&crate::Data>,
191        stderr: Option<&crate::Data>,
192        exit: Option<std::process::ExitStatus>,
193    ) -> Result<(), crate::Error> {
194        if let Some(ext) = path.extension() {
195            if ext == std::ffi::OsStr::new("toml") {
196                assert_eq!(id, None);
197
198                overwrite_toml_output(path, id, stdout, "stdout", "stdout")?;
199                overwrite_toml_output(path, id, stderr, "stderr", "stderr")?;
200
201                if let Some(status) = exit {
202                    let raw = std::fs::read_to_string(path)
203                        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
204                    let overwritten = overwrite_toml_status(status, raw)
205                        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
206                    std::fs::write(path, overwritten)
207                        .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
208                }
209            } else if ext == std::ffi::OsStr::new("trycmd") || ext == std::ffi::OsStr::new("md") {
210                if stderr.is_some() && stderr != Some(&crate::Data::new()) {
211                    panic!("stderr should have been merged: {:?}", stderr);
212                }
213                if let (Some(id), Some(stdout)) = (id, stdout) {
214                    let step = self
215                        .steps
216                        .iter()
217                        .find(|s| s.id.as_deref() == Some(id))
218                        .expect("id is valid");
219                    let mut line_nums = step
220                        .expected_stdout_source
221                        .clone()
222                        .expect("always present for .trycmd");
223
224                    let raw = std::fs::read_to_string(path)
225                        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
226                    let mut normalized = snapbox::utils::normalize_lines(&raw);
227
228                    overwrite_trycmd_status(exit, step, &mut line_nums, &mut normalized)?;
229
230                    let mut stdout = stdout.render().expect("at least Text");
231                    // Add back trailing newline removed when parsing
232                    stdout.push('\n');
233                    replace_lines(&mut normalized, line_nums, &stdout)?;
234
235                    std::fs::write(path, normalized.into_bytes())
236                        .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
237                }
238            } else {
239                return Err(format!("Unsupported extension: {}", ext.to_string_lossy()).into());
240            }
241        } else {
242            return Err("No extension".into());
243        }
244
245        Ok(())
246    }
247
248    fn parse_trycmd(s: &str) -> Result<Self, crate::Error> {
249        let mut steps = Vec::new();
250
251        let mut lines: VecDeque<_> = snapbox::utils::LinesWithTerminator::new(s)
252            .enumerate()
253            .map(|(i, l)| (i + 1, l))
254            .collect();
255        'outer: loop {
256            let mut fence_pattern = "```".to_owned();
257            while let Some((_, line)) = lines.pop_front() {
258                let tick_end = line
259                    .char_indices()
260                    .find_map(|(i, c)| (c != '`').then_some(i))
261                    .unwrap_or(line.len());
262                if 3 <= tick_end {
263                    fence_pattern = line[..tick_end].to_owned();
264                    let raw = line[tick_end..].trim();
265                    if raw.is_empty() {
266                        // Assuming a trycmd block
267                        break;
268                    } else {
269                        let mut info = raw.split(',');
270                        let lang = info.next().unwrap();
271                        match lang {
272                            "trycmd" | "console" => {
273                                if info.any(|i| i == "ignore") {
274                                    snapbox::debug!("ignore from infostring: {:?}", info);
275                                } else {
276                                    break;
277                                }
278                            }
279                            _ => {
280                                snapbox::debug!("ignore from lang: {:?}", lang);
281                            }
282                        }
283                    }
284
285                    // Irrelevant block, consume to end
286                    while let Some((_, line)) = lines.pop_front() {
287                        if line.starts_with(&fence_pattern) {
288                            continue 'outer;
289                        }
290                    }
291                }
292            }
293
294            'code: loop {
295                let mut cmdline = Vec::new();
296                let mut expected_status_source = None;
297                let mut expected_status = Some(CommandStatus::Success);
298                let mut stdout = String::new();
299                let cmd_start;
300                let mut stdout_start;
301
302                if let Some((line_num, line)) = lines.pop_front() {
303                    if line.starts_with(&fence_pattern) {
304                        break;
305                    } else if let Some(raw) = line.strip_prefix("$ ") {
306                        cmdline.extend(shlex::Shlex::new(raw.trim()));
307                        cmd_start = line_num;
308                        stdout_start = line_num + 1;
309                    } else {
310                        return Err(
311                            format!("Expected `$` on line {}, got `{}`", line_num, line).into()
312                        );
313                    }
314                } else {
315                    break 'outer;
316                }
317                while let Some((line_num, line)) = lines.pop_front() {
318                    if let Some(raw) = line.strip_prefix("> ") {
319                        cmdline.extend(shlex::Shlex::new(raw.trim()));
320                        stdout_start = line_num + 1;
321                    } else {
322                        lines.push_front((line_num, line));
323                        break;
324                    }
325                }
326                if let Some((line_num, line)) = lines.pop_front() {
327                    if let Some(raw) = line.strip_prefix("? ") {
328                        expected_status_source = Some(line_num);
329                        expected_status = Some(raw.trim().parse::<CommandStatus>()?);
330                        stdout_start = line_num + 1;
331                    } else {
332                        lines.push_front((line_num, line));
333                    }
334                }
335                let mut post_stdout_start = stdout_start;
336                let mut block_done = false;
337                while let Some((line_num, line)) = lines.pop_front() {
338                    if line.starts_with("$ ") {
339                        lines.push_front((line_num, line));
340                        post_stdout_start = line_num;
341                        break;
342                    } else if line.starts_with(&fence_pattern) {
343                        block_done = true;
344                        post_stdout_start = line_num;
345                        break;
346                    } else {
347                        stdout.push_str(line);
348                        post_stdout_start = line_num + 1;
349                    }
350                }
351                if stdout.ends_with('\n') {
352                    // Last newline is for formatting purposes so tests can verify cases without a
353                    // trailing newline.
354                    stdout.pop();
355                }
356
357                let mut env = Env::default();
358
359                let bin = loop {
360                    if cmdline.is_empty() {
361                        return Err(format!("No bin specified on line {}", cmd_start).into());
362                    }
363                    let next = cmdline.remove(0);
364                    if let Some((key, value)) = next.split_once('=') {
365                        env.add.insert(key.to_owned(), value.to_owned());
366                    } else {
367                        break next;
368                    }
369                };
370                let step = Step {
371                    id: Some(cmd_start.to_string()),
372                    bin: Some(Bin::Name(bin)),
373                    args: cmdline,
374                    env,
375                    stdin: None,
376                    stderr_to_stdout: true,
377                    expected_status_source,
378                    expected_status,
379                    expected_stdout_source: Some(stdout_start..post_stdout_start),
380                    expected_stdout: Some(crate::Data::text(stdout)),
381                    expected_stderr_source: None,
382                    expected_stderr: None,
383                    binary: false,
384                    timeout: None,
385                };
386                steps.push(step);
387                if block_done {
388                    break 'code;
389                }
390            }
391        }
392
393        Ok(Self {
394            steps,
395            ..Default::default()
396        })
397    }
398}
399
400fn overwrite_toml_output(
401    path: &std::path::Path,
402    _id: Option<&str>,
403    output: Option<&crate::Data>,
404    output_ext: &str,
405    output_field: &str,
406) -> Result<(), crate::Error> {
407    if let Some(output) = output {
408        let output_path = path.with_extension(output_ext);
409        if output_path.exists() {
410            output.write_to(&output_path)?;
411        } else if let Some(output) = output.render() {
412            let raw = std::fs::read_to_string(path)
413                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
414            let mut doc = raw
415                .parse::<toml_edit::Document>()
416                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
417            if let Some(output_value) = doc.get_mut(output_field) {
418                *output_value = toml_edit::value(output);
419            }
420            std::fs::write(path, doc.to_string())
421                .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
422        } else {
423            output.write_to(&output_path)?;
424
425            let raw = std::fs::read_to_string(path)
426                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
427            let mut doc = raw
428                .parse::<toml_edit::Document>()
429                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
430            doc[output_field] = toml_edit::Item::None;
431            std::fs::write(path, doc.to_string())
432                .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
433        }
434    }
435
436    Ok(())
437}
438
439fn overwrite_toml_status(
440    status: std::process::ExitStatus,
441    raw: String,
442) -> Result<String, toml_edit::TomlError> {
443    let mut doc = raw.parse::<toml_edit::Document>()?;
444    if let Some(code) = status.code() {
445        if status.success() {
446            match doc.get("status") {
447                Some(toml_edit::Item::Value(toml_edit::Value::String(ref expected)))
448                    if expected.value() == "success" => {}
449                Some(
450                    toml_edit::Item::Value(toml_edit::Value::InlineTable(_))
451                    | toml_edit::Item::Table(_),
452                ) => {
453                    if !matches!(
454                        doc["status"].get("code"),
455                        Some(toml_edit::Item::Value(toml_edit::Value::Integer(ref expected)))
456                            if expected.value() == &0)
457                    {
458                        // Remove `status` to use the default value (success)
459                        doc["status"] = toml_edit::Item::None;
460                    }
461                }
462                _ => {
463                    // Remove `status` to use the default value (success)
464                    doc["status"] = toml_edit::Item::None;
465                }
466            }
467        } else {
468            let code = code as i64;
469            match doc.get("status") {
470                Some(toml_edit::Item::Value(toml_edit::Value::String(ref expected))) => {
471                    if expected.value() != "failed" {
472                        doc["status"] = toml_edit::value("failed");
473                    }
474                }
475                Some(
476                    toml_edit::Item::Value(toml_edit::Value::InlineTable(_))
477                    | toml_edit::Item::Table(_),
478                ) => {
479                    if !matches!(
480                        doc["status"].get("code"),
481                        Some(toml_edit::Item::Value(toml_edit::Value::Integer(ref expected)))
482                            if expected.value() == &code)
483                    {
484                        doc["status"]["code"] = toml_edit::value(code);
485                    }
486                }
487                _ => {
488                    let mut status = toml_edit::InlineTable::default();
489                    status.set_dotted(true);
490                    status.insert("code", code.into());
491                    doc["status"] = toml_edit::value(status);
492                }
493            }
494        }
495    } else if !matches!(
496        doc.get("status"),
497        Some(toml_edit::Item::Value(toml_edit::Value::String(ref expected)))
498            if expected.value() == "interrupted")
499    {
500        doc["status"] = toml_edit::value("interrupted");
501    }
502
503    Ok(doc.to_string())
504}
505
506fn overwrite_trycmd_status(
507    exit: Option<std::process::ExitStatus>,
508    step: &Step,
509    stdout_line_nums: &mut std::ops::Range<usize>,
510    normalized: &mut String,
511) -> Result<(), snapbox::Error> {
512    let status = match exit {
513        Some(status) => status,
514        _ => {
515            return Ok(());
516        }
517    };
518
519    let formatted_status = if let Some(code) = status.code() {
520        if status.success() {
521            if let (true, Some(line_num)) = (
522                step.expected_status != Some(CommandStatus::Success),
523                step.expected_status_source,
524            ) {
525                replace_lines(normalized, line_num..(line_num + 1), "")?;
526                *stdout_line_nums = (stdout_line_nums.start - 1)..(stdout_line_nums.end - 1);
527            }
528            None
529        } else {
530            match step.expected_status {
531                Some(CommandStatus::Success | CommandStatus::Interrupted) => {
532                    Some(format!("? {code}"))
533                }
534                Some(CommandStatus::Code(expected)) if expected != code => {
535                    Some(format!("? {code}"))
536                }
537                _ => None,
538            }
539        }
540    } else {
541        if step.expected_status == Some(CommandStatus::Interrupted) {
542            None
543        } else {
544            Some("? interrupted".into())
545        }
546    };
547
548    if let Some(status) = formatted_status {
549        if let Some(line_num) = step.expected_status_source {
550            replace_lines(normalized, line_num..(line_num + 1), &status)?;
551        } else {
552            let line_num = stdout_line_nums.start;
553            replace_lines(normalized, line_num..line_num, &status)?;
554            *stdout_line_nums = (line_num + 1)..(stdout_line_nums.end + 1);
555        }
556    }
557
558    Ok(())
559}
560
561/// Update an inline snapshot
562fn replace_lines(
563    data: &mut String,
564    line_nums: std::ops::Range<usize>,
565    text: &str,
566) -> Result<(), crate::Error> {
567    let mut output_lines = String::new();
568
569    for (line_num, line) in snapbox::utils::LinesWithTerminator::new(data)
570        .enumerate()
571        .map(|(i, l)| (i + 1, l))
572    {
573        if line_num == line_nums.start {
574            output_lines.push_str(text);
575            if !text.is_empty() && !text.ends_with('\n') {
576                output_lines.push('\n');
577            }
578        }
579        if !line_nums.contains(&line_num) {
580            output_lines.push_str(line);
581        }
582    }
583
584    *data = output_lines;
585    Ok(())
586}
587
588impl std::str::FromStr for TryCmd {
589    type Err = crate::Error;
590
591    fn from_str(s: &str) -> Result<Self, Self::Err> {
592        Self::parse_trycmd(s)
593    }
594}
595
596impl From<OneShot> for TryCmd {
597    fn from(other: OneShot) -> Self {
598        let OneShot {
599            bin,
600            args,
601            env,
602            stdin,
603            stdout,
604            stderr,
605            stderr_to_stdout,
606            status,
607            binary,
608            timeout,
609            fs,
610        } = other;
611        Self {
612            steps: vec![Step {
613                id: None,
614                bin,
615                args: args.into_vec(),
616                env,
617                stdin: stdin.map(crate::Data::text),
618                stderr_to_stdout,
619                expected_status_source: None,
620                expected_status: status,
621                expected_stdout_source: None,
622                expected_stdout: stdout.map(crate::Data::text),
623                expected_stderr_source: None,
624                expected_stderr: stderr.map(crate::Data::text),
625                binary,
626                timeout,
627            }],
628            fs,
629        }
630    }
631}
632
633/// A command invocation and its expected result.
634#[derive(Clone, Default, Debug, PartialEq, Eq)]
635pub struct Step {
636    /// Uniquely identifies this step from others.
637    pub id: Option<String>,
638    /// The program that will be executed.
639    pub bin: Option<Bin>,
640    /// Arguments to the executed program.
641    pub args: Vec<String>,
642    /// Environment variables for the execution.
643    pub env: Env,
644    /// Process stdin
645    pub stdin: Option<crate::Data>,
646    /// Whether to redirect stderr to stdout.
647    pub stderr_to_stdout: bool,
648    pub expected_status_source: Option<usize>,
649    /// Expected command status result.
650    pub expected_status: Option<CommandStatus>,
651    pub expected_stdout_source: Option<std::ops::Range<usize>>,
652    /// Expected process stdout content.
653    pub expected_stdout: Option<crate::Data>,
654    pub expected_stderr_source: Option<std::ops::Range<usize>>,
655    /// Expected process stderr content.
656    pub expected_stderr: Option<crate::Data>,
657    /// Whether process output is binary (as opposed to text).
658    pub binary: bool,
659    /// How long to wait for process to exit before timing out.
660    pub timeout: Option<std::time::Duration>,
661}
662
663impl Step {
664    pub(crate) fn to_command(
665        &self,
666        cwd: Option<&std::path::Path>,
667    ) -> Result<snapbox::cmd::Command, crate::Error> {
668        let bin = match &self.bin {
669            Some(Bin::Path(path)) => Ok(path.clone()),
670            Some(Bin::Name(name)) => Err(format!("Unknown bin.name = {}", name).into()),
671            Some(Bin::Ignore) => Err("Internal error: tried to run an ignored bin".into()),
672            Some(Bin::Error(err)) => Err(err.clone()),
673            None => Err("No bin specified".into()),
674        }?;
675        if !bin.exists() {
676            return Err(format!("Bin doesn't exist: {}", bin.display()).into());
677        }
678
679        let mut cmd = snapbox::cmd::Command::new(bin).args(&self.args);
680        if let Some(cwd) = cwd {
681            cmd = cmd.current_dir(cwd);
682        }
683        if let Some(stdin) = &self.stdin {
684            cmd = cmd.stdin(stdin);
685        }
686        if self.stderr_to_stdout {
687            cmd = cmd.stderr_to_stdout();
688        }
689        if let Some(timeout) = self.timeout {
690            cmd = cmd.timeout(timeout)
691        }
692        cmd = self.env.apply(cmd);
693
694        Ok(cmd)
695    }
696
697    pub(crate) fn expected_status(&self) -> CommandStatus {
698        self.expected_status.unwrap_or_default()
699    }
700}
701
702/// Top-level data in `cmd.toml` files
703#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
704#[serde(rename_all = "kebab-case")]
705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
706pub struct OneShot {
707    pub(crate) bin: Option<Bin>,
708    #[serde(default)]
709    pub(crate) args: Args,
710    #[serde(default)]
711    pub(crate) env: Env,
712    #[serde(default)]
713    pub(crate) stdin: Option<String>,
714    #[serde(default)]
715    pub(crate) stdout: Option<String>,
716    #[serde(default)]
717    pub(crate) stderr: Option<String>,
718    #[serde(default)]
719    pub(crate) stderr_to_stdout: bool,
720    pub(crate) status: Option<CommandStatus>,
721    #[serde(default)]
722    pub(crate) binary: bool,
723    #[serde(default)]
724    #[serde(deserialize_with = "humantime_serde::deserialize")]
725    pub(crate) timeout: Option<std::time::Duration>,
726    #[serde(default)]
727    pub(crate) fs: Filesystem,
728}
729
730impl OneShot {
731    fn parse_toml(s: &str) -> Result<Self, crate::Error> {
732        toml_edit::de::from_str(s).map_err(|e| e.to_string().into())
733    }
734}
735
736#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
737#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
738#[serde(untagged)]
739pub(crate) enum Args {
740    Joined(JoinedArgs),
741    Split(Vec<String>),
742}
743
744impl Args {
745    fn new() -> Self {
746        Self::Split(Default::default())
747    }
748
749    fn as_slice(&self) -> &[String] {
750        match self {
751            Self::Joined(j) => j.inner.as_slice(),
752            Self::Split(v) => v.as_slice(),
753        }
754    }
755
756    fn into_vec(self) -> Vec<String> {
757        match self {
758            Self::Joined(j) => j.inner,
759            Self::Split(v) => v,
760        }
761    }
762}
763
764impl Default for Args {
765    fn default() -> Self {
766        Self::new()
767    }
768}
769
770impl std::ops::Deref for Args {
771    type Target = [String];
772
773    fn deref(&self) -> &Self::Target {
774        self.as_slice()
775    }
776}
777
778#[derive(Clone, Default, Debug, PartialEq, Eq)]
779#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
780pub(crate) struct JoinedArgs {
781    inner: Vec<String>,
782}
783
784impl JoinedArgs {
785    #[cfg(test)]
786    pub(crate) fn from_vec(inner: Vec<String>) -> Self {
787        JoinedArgs { inner }
788    }
789
790    #[allow(clippy::inherent_to_string_shadow_display)]
791    fn to_string(&self) -> String {
792        shlex::join(self.inner.iter().map(|s| s.as_str()))
793    }
794}
795
796impl std::str::FromStr for JoinedArgs {
797    type Err = std::convert::Infallible;
798
799    fn from_str(s: &str) -> Result<Self, Self::Err> {
800        let inner = shlex::Shlex::new(s).collect();
801        Ok(Self { inner })
802    }
803}
804
805impl std::fmt::Display for JoinedArgs {
806    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
807        self.to_string().fmt(f)
808    }
809}
810
811impl<'de> serde::de::Deserialize<'de> for JoinedArgs {
812    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
813    where
814        D: serde::de::Deserializer<'de>,
815    {
816        let s = String::deserialize(deserializer)?;
817        std::str::FromStr::from_str(&s).map_err(serde::de::Error::custom)
818    }
819}
820
821impl serde::ser::Serialize for JoinedArgs {
822    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
823    where
824        S: serde::ser::Serializer,
825    {
826        serializer.serialize_str(&self.to_string())
827    }
828}
829
830/// Describe the command's filesystem context
831#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
832#[serde(rename_all = "kebab-case")]
833#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
834pub struct Filesystem {
835    /// Current working directory.
836    pub cwd: Option<std::path::PathBuf>,
837    /// Sandbox base
838    pub base: Option<std::path::PathBuf>,
839    /// Whether to create a sandboxed copy of the base at run-time.
840    pub sandbox: Option<bool>,
841}
842
843impl Filesystem {
844    pub(crate) fn sandbox(&self) -> bool {
845        self.sandbox.unwrap_or_default()
846    }
847
848    pub(crate) fn rel_cwd(&self) -> Result<&std::path::Path, crate::Error> {
849        if let (Some(orig_cwd), Some(orig_base)) = (self.cwd.as_deref(), self.base.as_deref()) {
850            let rel_cwd = orig_cwd.strip_prefix(orig_base).map_err(|_| {
851                crate::Error::new(format!(
852                    "fs.cwd ({}) must be within fs.base ({})",
853                    orig_cwd.display(),
854                    orig_base.display()
855                ))
856            })?;
857            Ok(rel_cwd)
858        } else {
859            Ok(std::path::Path::new(""))
860        }
861    }
862}
863
864/// Describe command's environment
865#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
866#[serde(rename_all = "kebab-case")]
867#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
868pub struct Env {
869    #[serde(default)]
870    pub inherit: Option<bool>,
871    #[serde(default)]
872    pub add: BTreeMap<String, String>,
873    #[serde(default)]
874    pub remove: Vec<String>,
875}
876
877impl Env {
878    pub fn update(&mut self, other: &Self) {
879        if self.inherit.is_none() {
880            self.inherit = other.inherit;
881        }
882        self.add
883            .extend(other.add.iter().map(|(k, v)| (k.clone(), v.clone())));
884        self.remove.extend(other.remove.iter().cloned());
885    }
886
887    pub(crate) fn apply(&self, mut command: snapbox::cmd::Command) -> snapbox::cmd::Command {
888        if !self.inherit() {
889            command = command.env_clear();
890        }
891        for remove in &self.remove {
892            command = command.env_remove(remove);
893        }
894        command.envs(&self.add)
895    }
896
897    pub(crate) fn inherit(&self) -> bool {
898        self.inherit.unwrap_or(true)
899    }
900}
901
902/// Target under test
903#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
904#[serde(rename_all = "kebab-case")]
905#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
906pub enum Bin {
907    Path(std::path::PathBuf),
908    Name(String),
909    Ignore,
910    #[serde(skip)]
911    Error(crate::Error),
912}
913
914impl From<std::path::PathBuf> for Bin {
915    fn from(other: std::path::PathBuf) -> Self {
916        Self::Path(other)
917    }
918}
919
920impl<'a> From<&'a std::path::PathBuf> for Bin {
921    fn from(other: &'a std::path::PathBuf) -> Self {
922        Self::Path(other.clone())
923    }
924}
925
926impl<'a> From<&'a std::path::Path> for Bin {
927    fn from(other: &'a std::path::Path) -> Self {
928        Self::Path(other.to_owned())
929    }
930}
931
932impl<P, E> From<Result<P, E>> for Bin
933where
934    P: Into<Bin>,
935    E: std::fmt::Display,
936{
937    fn from(other: Result<P, E>) -> Self {
938        match other {
939            Ok(path) => path.into(),
940            Err(err) => {
941                let err = crate::Error::new(err.to_string());
942                Bin::Error(err)
943            }
944        }
945    }
946}
947
948/// Expected status for command
949#[derive(Copy, Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
950#[serde(rename_all = "kebab-case")]
951#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
952#[derive(Default)]
953pub enum CommandStatus {
954    #[default]
955    Success,
956    Failed,
957    Interrupted,
958    Skipped,
959    Code(i32),
960}
961
962impl std::str::FromStr for CommandStatus {
963    type Err = crate::Error;
964
965    fn from_str(s: &str) -> Result<Self, Self::Err> {
966        match s {
967            "success" => Ok(Self::Success),
968            "failed" => Ok(Self::Failed),
969            "interrupted" => Ok(Self::Interrupted),
970            "skipped" => Ok(Self::Skipped),
971            _ => s
972                .parse::<i32>()
973                .map(Self::Code)
974                .map_err(|_| crate::Error::new(format!("Expected an exit code, got {}", s))),
975        }
976    }
977}
978
979#[cfg(test)]
980mod test {
981    use super::*;
982
983    #[test]
984    fn parse_trycmd_empty() {
985        let expected = TryCmd {
986            steps: vec![],
987            ..Default::default()
988        };
989        let actual = TryCmd::parse_trycmd("").unwrap();
990        assert_eq!(expected, actual);
991    }
992
993    #[test]
994    fn parse_trycmd_empty_fence() {
995        let expected = TryCmd {
996            steps: vec![],
997            ..Default::default()
998        };
999        let actual = TryCmd::parse_trycmd(
1000            "
1001```
1002```
1003",
1004        )
1005        .unwrap();
1006        assert_eq!(expected, actual);
1007    }
1008
1009    #[test]
1010    fn parse_trycmd_command() {
1011        let expected = TryCmd {
1012            steps: vec![Step {
1013                id: Some("3".into()),
1014                bin: Some(Bin::Name("cmd".into())),
1015                expected_status: Some(CommandStatus::Success),
1016                stderr_to_stdout: true,
1017                expected_stdout_source: Some(4..4),
1018                expected_stdout: Some(crate::Data::new()),
1019                expected_stderr: None,
1020                ..Default::default()
1021            }],
1022            ..Default::default()
1023        };
1024        let actual = TryCmd::parse_trycmd(
1025            "
1026```
1027$ cmd
1028```
1029",
1030        )
1031        .unwrap();
1032        assert_eq!(expected, actual);
1033    }
1034
1035    #[test]
1036    fn parse_trycmd_command_line() {
1037        let expected = TryCmd {
1038            steps: vec![Step {
1039                id: Some("3".into()),
1040                bin: Some(Bin::Name("cmd".into())),
1041                args: vec!["arg1".into(), "arg with space".into()],
1042                expected_status: Some(CommandStatus::Success),
1043                stderr_to_stdout: true,
1044                expected_stdout_source: Some(4..4),
1045                expected_stdout: Some(crate::Data::new()),
1046                expected_stderr: None,
1047                ..Default::default()
1048            }],
1049            ..Default::default()
1050        };
1051        let actual = TryCmd::parse_trycmd(
1052            "
1053```
1054$ cmd arg1 'arg with space'
1055```
1056",
1057        )
1058        .unwrap();
1059        assert_eq!(expected, actual);
1060    }
1061
1062    #[test]
1063    fn parse_trycmd_multi_line() {
1064        let expected = TryCmd {
1065            steps: vec![Step {
1066                id: Some("3".into()),
1067                bin: Some(Bin::Name("cmd".into())),
1068                args: vec!["arg1".into(), "arg with space".into()],
1069                expected_status: Some(CommandStatus::Success),
1070                stderr_to_stdout: true,
1071                expected_stdout_source: Some(5..5),
1072                expected_stdout: Some(crate::Data::new()),
1073                expected_stderr: None,
1074                ..Default::default()
1075            }],
1076            ..Default::default()
1077        };
1078        let actual = TryCmd::parse_trycmd(
1079            "
1080```
1081$ cmd arg1
1082> 'arg with space'
1083```
1084",
1085        )
1086        .unwrap();
1087        assert_eq!(expected, actual);
1088    }
1089
1090    #[test]
1091    fn parse_trycmd_env() {
1092        let expected = TryCmd {
1093            steps: vec![Step {
1094                id: Some("3".into()),
1095                bin: Some(Bin::Name("cmd".into())),
1096                env: Env {
1097                    add: IntoIterator::into_iter([
1098                        ("KEY1".into(), "VALUE1".into()),
1099                        ("KEY2".into(), "VALUE2 with space".into()),
1100                    ])
1101                    .collect(),
1102                    ..Default::default()
1103                },
1104                expected_status: Some(CommandStatus::Success),
1105                stderr_to_stdout: true,
1106                expected_stdout_source: Some(4..4),
1107                expected_stdout: Some(crate::Data::new()),
1108                expected_stderr: None,
1109                ..Default::default()
1110            }],
1111            ..Default::default()
1112        };
1113        let actual = TryCmd::parse_trycmd(
1114            "
1115```
1116$ KEY1=VALUE1 KEY2='VALUE2 with space' cmd
1117```
1118",
1119        )
1120        .unwrap();
1121        assert_eq!(expected, actual);
1122    }
1123
1124    #[test]
1125    fn parse_trycmd_status() {
1126        let expected = TryCmd {
1127            steps: vec![Step {
1128                id: Some("3".into()),
1129                bin: Some(Bin::Name("cmd".into())),
1130                expected_status_source: Some(4),
1131                expected_status: Some(CommandStatus::Skipped),
1132                stderr_to_stdout: true,
1133                expected_stdout_source: Some(5..5),
1134                expected_stdout: Some(crate::Data::new()),
1135                expected_stderr: None,
1136                ..Default::default()
1137            }],
1138            ..Default::default()
1139        };
1140        let actual = TryCmd::parse_trycmd(
1141            "
1142```
1143$ cmd
1144? skipped
1145```
1146",
1147        )
1148        .unwrap();
1149        assert_eq!(expected, actual);
1150    }
1151
1152    #[test]
1153    fn parse_trycmd_status_code() {
1154        let expected = TryCmd {
1155            steps: vec![Step {
1156                id: Some("3".into()),
1157                bin: Some(Bin::Name("cmd".into())),
1158                expected_status_source: Some(4),
1159                expected_status: Some(CommandStatus::Code(-1)),
1160                stderr_to_stdout: true,
1161                expected_stdout_source: Some(5..5),
1162                expected_stdout: Some(crate::Data::new()),
1163                expected_stderr: None,
1164                ..Default::default()
1165            }],
1166            ..Default::default()
1167        };
1168        let actual = TryCmd::parse_trycmd(
1169            "
1170```
1171$ cmd
1172? -1
1173```
1174",
1175        )
1176        .unwrap();
1177        assert_eq!(expected, actual);
1178    }
1179
1180    #[test]
1181    fn parse_trycmd_stdout() {
1182        let expected = TryCmd {
1183            steps: vec![Step {
1184                id: Some("3".into()),
1185                bin: Some(Bin::Name("cmd".into())),
1186                expected_status: Some(CommandStatus::Success),
1187                stderr_to_stdout: true,
1188                expected_stdout_source: Some(4..6),
1189                expected_stdout: Some(crate::Data::text("Hello World\n")),
1190                expected_stderr: None,
1191                ..Default::default()
1192            }],
1193            ..Default::default()
1194        };
1195        let actual = TryCmd::parse_trycmd(
1196            "
1197```
1198$ cmd
1199Hello World
1200
1201```",
1202        )
1203        .unwrap();
1204        assert_eq!(expected, actual);
1205    }
1206
1207    #[test]
1208    fn parse_trycmd_escaped_stdout() {
1209        let expected = TryCmd {
1210            steps: vec![Step {
1211                id: Some("3".into()),
1212                bin: Some(Bin::Name("cmd".into())),
1213                expected_status: Some(CommandStatus::Success),
1214                stderr_to_stdout: true,
1215                expected_stdout_source: Some(4..7),
1216                expected_stdout: Some(crate::Data::text("```\nHello World\n```")),
1217                expected_stderr: None,
1218                ..Default::default()
1219            }],
1220            ..Default::default()
1221        };
1222        let actual = TryCmd::parse_trycmd(
1223            "
1224````
1225$ cmd
1226```
1227Hello World
1228```
1229````",
1230        )
1231        .unwrap();
1232        assert_eq!(expected, actual);
1233    }
1234
1235    #[test]
1236    fn parse_trycmd_multi_step() {
1237        let expected = TryCmd {
1238            steps: vec![
1239                Step {
1240                    id: Some("3".into()),
1241                    bin: Some(Bin::Name("cmd1".into())),
1242                    expected_status_source: Some(4),
1243                    expected_status: Some(CommandStatus::Code(1)),
1244                    stderr_to_stdout: true,
1245                    expected_stdout_source: Some(5..5),
1246                    expected_stdout: Some(crate::Data::new()),
1247                    expected_stderr: None,
1248                    ..Default::default()
1249                },
1250                Step {
1251                    id: Some("5".into()),
1252                    bin: Some(Bin::Name("cmd2".into())),
1253                    expected_status: Some(CommandStatus::Success),
1254                    stderr_to_stdout: true,
1255                    expected_stdout_source: Some(6..6),
1256                    expected_stdout: Some(crate::Data::new()),
1257                    expected_stderr: None,
1258                    ..Default::default()
1259                },
1260            ],
1261            ..Default::default()
1262        };
1263        let actual = TryCmd::parse_trycmd(
1264            "
1265```
1266$ cmd1
1267? 1
1268$ cmd2
1269```
1270",
1271        )
1272        .unwrap();
1273        assert_eq!(expected, actual);
1274    }
1275
1276    #[test]
1277    fn parse_trycmd_info_string() {
1278        let expected = TryCmd {
1279            steps: vec![
1280                Step {
1281                    id: Some("3".into()),
1282                    bin: Some(Bin::Name("bare-cmd".into())),
1283                    expected_status_source: Some(4),
1284                    expected_status: Some(CommandStatus::Code(1)),
1285                    stderr_to_stdout: true,
1286                    expected_stdout_source: Some(5..5),
1287                    expected_stdout: Some(crate::Data::new()),
1288                    expected_stderr: None,
1289                    ..Default::default()
1290                },
1291                Step {
1292                    id: Some("8".into()),
1293                    bin: Some(Bin::Name("trycmd-cmd".into())),
1294                    expected_status_source: Some(9),
1295                    expected_status: Some(CommandStatus::Code(1)),
1296                    stderr_to_stdout: true,
1297                    expected_stdout_source: Some(10..10),
1298                    expected_stdout: Some(crate::Data::new()),
1299                    expected_stderr: None,
1300                    ..Default::default()
1301                },
1302                Step {
1303                    id: Some("18".into()),
1304                    bin: Some(Bin::Name("console-cmd".into())),
1305                    expected_status_source: Some(19),
1306                    expected_status: Some(CommandStatus::Code(1)),
1307                    stderr_to_stdout: true,
1308                    expected_stdout_source: Some(20..20),
1309                    expected_stdout: Some(crate::Data::new()),
1310                    expected_stderr: None,
1311                    ..Default::default()
1312                },
1313            ],
1314            ..Default::default()
1315        };
1316        let actual = TryCmd::parse_trycmd(
1317            "
1318```
1319$ bare-cmd
1320? 1
1321```
1322
1323```trycmd
1324$ trycmd-cmd
1325? 1
1326```
1327
1328```sh
1329$ sh-cmd
1330? 1
1331```
1332
1333```console
1334$ console-cmd
1335? 1
1336```
1337
1338```ignore
1339$ rust-cmd1
1340? 1
1341```
1342
1343```trycmd,ignore
1344$ rust-cmd1
1345? 1
1346```
1347
1348```rust
1349$ rust-cmd1
1350? 1
1351```
1352",
1353        )
1354        .unwrap();
1355        assert_eq!(expected, actual);
1356    }
1357
1358    #[test]
1359    fn parse_toml_minimal() {
1360        let expected = OneShot {
1361            ..Default::default()
1362        };
1363        let actual = OneShot::parse_toml("").unwrap();
1364        assert_eq!(expected, actual);
1365    }
1366
1367    #[test]
1368    fn parse_toml_minimal_env() {
1369        let expected = OneShot {
1370            ..Default::default()
1371        };
1372        let actual = OneShot::parse_toml("[env]").unwrap();
1373        assert_eq!(expected, actual);
1374    }
1375
1376    #[test]
1377    fn parse_toml_bin_name() {
1378        let expected = OneShot {
1379            bin: Some(Bin::Name("cmd".into())),
1380            ..Default::default()
1381        };
1382        let actual = OneShot::parse_toml("bin.name = 'cmd'").unwrap();
1383        assert_eq!(expected, actual);
1384    }
1385
1386    #[test]
1387    fn parse_toml_bin_path() {
1388        let expected = OneShot {
1389            bin: Some(Bin::Path("/usr/bin/cmd".into())),
1390            ..Default::default()
1391        };
1392        let actual = OneShot::parse_toml("bin.path = '/usr/bin/cmd'").unwrap();
1393        assert_eq!(expected, actual);
1394    }
1395
1396    #[test]
1397    fn parse_toml_args_split() {
1398        let expected = OneShot {
1399            args: Args::Split(vec!["arg1".into(), "arg with space".into()]),
1400            ..Default::default()
1401        };
1402        let actual = OneShot::parse_toml(r#"args = ["arg1", "arg with space"]"#).unwrap();
1403        assert_eq!(expected, actual);
1404    }
1405
1406    #[test]
1407    fn parse_toml_args_joined() {
1408        let expected = OneShot {
1409            args: Args::Joined(JoinedArgs::from_vec(vec![
1410                "arg1".into(),
1411                "arg with space".into(),
1412            ])),
1413            ..Default::default()
1414        };
1415        let actual = OneShot::parse_toml(r#"args = "arg1 'arg with space'""#).unwrap();
1416        assert_eq!(expected, actual);
1417    }
1418
1419    #[test]
1420    fn parse_toml_status_success() {
1421        let expected = OneShot {
1422            status: Some(CommandStatus::Success),
1423            ..Default::default()
1424        };
1425        let actual = OneShot::parse_toml("status = 'success'").unwrap();
1426        assert_eq!(expected, actual);
1427    }
1428
1429    #[test]
1430    fn parse_toml_status_code() {
1431        let expected = OneShot {
1432            status: Some(CommandStatus::Code(42)),
1433            ..Default::default()
1434        };
1435        let actual = OneShot::parse_toml("status.code = 42").unwrap();
1436        assert_eq!(expected, actual);
1437    }
1438
1439    #[test]
1440    fn replace_lines_same_line_count() {
1441        let input = "One\nTwo\nThree";
1442        let line_nums = 2..3;
1443        let replacement = "World\n";
1444        let expected = "One\nWorld\nThree";
1445
1446        let mut actual = input.to_owned();
1447        replace_lines(&mut actual, line_nums, replacement).unwrap();
1448        assert_eq!(expected, actual);
1449    }
1450
1451    #[test]
1452    fn replace_lines_grow() {
1453        let input = "One\nTwo\nThree";
1454        let line_nums = 2..3;
1455        let replacement = "World\nTrees\n";
1456        let expected = "One\nWorld\nTrees\nThree";
1457
1458        let mut actual = input.to_owned();
1459        replace_lines(&mut actual, line_nums, replacement).unwrap();
1460        assert_eq!(expected, actual);
1461    }
1462
1463    #[test]
1464    fn replace_lines_shrink() {
1465        let input = "One\nTwo\nThree";
1466        let line_nums = 2..3;
1467        let replacement = "";
1468        let expected = "One\nThree";
1469
1470        let mut actual = input.to_owned();
1471        replace_lines(&mut actual, line_nums, replacement).unwrap();
1472        assert_eq!(expected, actual);
1473    }
1474
1475    #[test]
1476    fn replace_lines_no_trailing() {
1477        let input = "One\nTwo\nThree";
1478        let line_nums = 2..3;
1479        let replacement = "World";
1480        let expected = "One\nWorld\nThree";
1481
1482        let mut actual = input.to_owned();
1483        replace_lines(&mut actual, line_nums, replacement).unwrap();
1484        assert_eq!(expected, actual);
1485    }
1486
1487    #[test]
1488    fn replace_lines_empty_range() {
1489        let input = "One\nTwo\nThree";
1490        let line_nums = 2..2;
1491        let replacement = "World\n";
1492        let expected = "One\nWorld\nTwo\nThree";
1493
1494        let mut actual = input.to_owned();
1495        replace_lines(&mut actual, line_nums, replacement).unwrap();
1496        assert_eq!(expected, actual);
1497    }
1498
1499    #[test]
1500    fn overwrite_toml_status_success() {
1501        let expected = r#"
1502bin.name = "cmd"
1503"#;
1504        let actual = overwrite_toml_status(
1505            exit_code_to_status(0),
1506            r#"
1507bin.name = "cmd"
1508status = "failed"
1509"#
1510            .into(),
1511        )
1512        .unwrap();
1513        assert_eq!(expected, actual);
1514    }
1515
1516    #[test]
1517    fn overwrite_toml_status_failed() {
1518        let expected = r#"
1519bin.name = "cmd"
1520status.code = 1
1521"#;
1522        let actual = overwrite_toml_status(
1523            exit_code_to_status(1),
1524            r#"
1525bin.name = "cmd"
1526"#
1527            .into(),
1528        )
1529        .unwrap();
1530        assert_eq!(expected, actual);
1531    }
1532
1533    #[test]
1534    fn overwrite_toml_status_keeps_style() {
1535        let expected = r#"
1536bin.name = "cmd"
1537status = { code = 1 } # comment
1538"#;
1539        let actual = overwrite_toml_status(
1540            exit_code_to_status(1),
1541            r#"
1542bin.name = "cmd"
1543status = { code = 2 } # comment
1544"#
1545            .into(),
1546        )
1547        .unwrap();
1548        assert_eq!(expected, actual);
1549    }
1550
1551    #[test]
1552    fn overwrite_trycmd_status_success() {
1553        let expected = r#"
1554```
1555$ cmd arg
1556foo
1557bar
1558```
1559"#;
1560
1561        let mut actual = r"
1562```
1563$ cmd arg
1564? failed
1565foo
1566bar
1567```
1568"
1569        .to_owned();
1570
1571        let step = &TryCmd::parse_trycmd(&actual).unwrap().steps[0];
1572        overwrite_trycmd_status(
1573            Some(exit_code_to_status(0)),
1574            step,
1575            &mut step.expected_stdout_source.clone().unwrap(),
1576            &mut actual,
1577        )
1578        .unwrap();
1579
1580        assert_eq!(expected, actual);
1581    }
1582
1583    #[test]
1584    fn overwrite_trycmd_status_failed() {
1585        let expected = r#"
1586```
1587$ cmd arg
1588? 1
1589foo
1590bar
1591```
1592"#;
1593
1594        let mut actual = r"
1595```
1596$ cmd arg
1597? 2
1598foo
1599bar
1600```
1601"
1602        .to_owned();
1603
1604        let step = &TryCmd::parse_trycmd(&actual).unwrap().steps[0];
1605        overwrite_trycmd_status(
1606            Some(exit_code_to_status(1)),
1607            step,
1608            &mut step.expected_stdout_source.clone().unwrap(),
1609            &mut actual,
1610        )
1611        .unwrap();
1612
1613        assert_eq!(expected, actual);
1614    }
1615
1616    #[test]
1617    fn overwrite_trycmd_status_keeps_style() {
1618        let expected = r#"
1619```
1620$ cmd arg
1621? success
1622foo
1623bar
1624```
1625"#;
1626
1627        let mut actual = r"
1628```
1629$ cmd arg
1630? success
1631foo
1632bar
1633```
1634"
1635        .to_owned();
1636
1637        let step = &TryCmd::parse_trycmd(&actual).unwrap().steps[0];
1638        overwrite_trycmd_status(
1639            Some(exit_code_to_status(0)),
1640            step,
1641            &mut step.expected_stdout_source.clone().unwrap(),
1642            &mut actual,
1643        )
1644        .unwrap();
1645
1646        assert_eq!(expected, actual);
1647    }
1648
1649    #[cfg(unix)]
1650    fn exit_code_to_status(code: u8) -> std::process::ExitStatus {
1651        use std::os::unix::process::ExitStatusExt;
1652        std::process::ExitStatus::from_raw((code as i32) << 8)
1653    }
1654
1655    #[cfg(windows)]
1656    fn exit_code_to_status(code: u8) -> std::process::ExitStatus {
1657        use std::os::windows::process::ExitStatusExt;
1658        std::process::ExitStatus::from_raw(code as u32)
1659    }
1660
1661    #[test]
1662    fn exit_code_to_status_works() {
1663        assert_eq!(exit_code_to_status(42).code(), Some(42));
1664    }
1665}