trycmd/
schema.rs

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