1use 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 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 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 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 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 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 doc["status"] = toml_edit::Item::None;
396 }
397 }
398 _ => {
399 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
497fn 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#[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#[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 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#[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#[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#[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}