| //! `cmd.toml` Schema |
| //! |
| //! [`OneShot`] is the top-level item in the `cmd.toml` files. |
| |
| use std::collections::BTreeMap; |
| use std::collections::VecDeque; |
| use std::io::prelude::*; |
| |
| #[derive(Clone, Default, Debug, PartialEq, Eq)] |
| pub(crate) struct TryCmd { |
| pub(crate) steps: Vec<Step>, |
| pub(crate) fs: Filesystem, |
| } |
| |
| impl TryCmd { |
| pub(crate) fn load(path: &std::path::Path) -> Result<Self, crate::Error> { |
| let mut sequence = if let Some(ext) = path.extension() { |
| if ext == std::ffi::OsStr::new("toml") { |
| let raw = std::fs::read_to_string(path) |
| .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; |
| let one_shot = OneShot::parse_toml(&raw)?; |
| let mut sequence: Self = one_shot.into(); |
| let is_binary = sequence.steps[0].binary; |
| |
| if sequence.steps[0].stdin.is_none() { |
| let stdin_path = path.with_extension("stdin"); |
| let stdin = if stdin_path.exists() { |
| Some(crate::File::read_from(&stdin_path, Some(is_binary))?) |
| } else { |
| None |
| }; |
| sequence.steps[0].stdin = stdin; |
| } |
| |
| if sequence.steps[0].expected_stdout.is_none() { |
| let stdout_path = path.with_extension("stdout"); |
| let stdout = if stdout_path.exists() { |
| Some(crate::File::read_from(&stdout_path, Some(is_binary))?) |
| } else { |
| None |
| }; |
| sequence.steps[0].expected_stdout = stdout; |
| } |
| |
| if sequence.steps[0].expected_stderr.is_none() { |
| let stderr_path = path.with_extension("stderr"); |
| let stderr = if stderr_path.exists() { |
| Some(crate::File::read_from(&stderr_path, Some(is_binary))?) |
| } else { |
| None |
| }; |
| sequence.steps[0].expected_stderr = stderr; |
| } |
| |
| sequence |
| } else if ext == std::ffi::OsStr::new("trycmd") || ext == std::ffi::OsStr::new("md") { |
| let raw = crate::File::read_from(path, Some(false))? |
| .into_utf8() |
| .unwrap(); |
| Self::parse_trycmd(&raw)? |
| } else { |
| return Err(format!("Unsupported extension: {}", ext.to_string_lossy()).into()); |
| } |
| } else { |
| return Err("No extension".into()); |
| }; |
| |
| sequence.fs.base = sequence.fs.base.take().map(|base| { |
| path.parent() |
| .unwrap_or_else(|| std::path::Path::new(".")) |
| .join(base) |
| }); |
| sequence.fs.cwd = sequence.fs.cwd.take().map(|cwd| { |
| path.parent() |
| .unwrap_or_else(|| std::path::Path::new(".")) |
| .join(cwd) |
| }); |
| |
| if sequence.fs.base.is_none() { |
| let base_path = path.with_extension("in"); |
| if base_path.exists() { |
| sequence.fs.base = Some(base_path); |
| } else if sequence.fs.cwd.is_some() { |
| sequence.fs.base = sequence.fs.cwd.clone(); |
| } |
| } |
| if sequence.fs.cwd.is_none() { |
| sequence.fs.cwd = sequence.fs.base.clone(); |
| } |
| if sequence.fs.sandbox.is_none() { |
| sequence.fs.sandbox = Some(path.with_extension("out").exists()); |
| } |
| |
| sequence.fs.base = sequence |
| .fs |
| .base |
| .take() |
| .map(|p| crate::filesystem::resolve_dir(p).map_err(|e| e.to_string())) |
| .transpose()?; |
| sequence.fs.cwd = sequence |
| .fs |
| .cwd |
| .take() |
| .map(|p| crate::filesystem::resolve_dir(p).map_err(|e| e.to_string())) |
| .transpose()?; |
| |
| Ok(sequence) |
| } |
| |
| pub(crate) fn overwrite( |
| &self, |
| path: &std::path::Path, |
| id: Option<&str>, |
| stdout: Option<&crate::File>, |
| stderr: Option<&crate::File>, |
| ) -> Result<(), crate::Error> { |
| if let Some(ext) = path.extension() { |
| if ext == std::ffi::OsStr::new("toml") { |
| assert_eq!(id, None); |
| |
| overwrite_toml_output(path, id, stdout, "stdout", "stdout")?; |
| overwrite_toml_output(path, id, stderr, "stderr", "stderr")?; |
| } else if ext == std::ffi::OsStr::new("trycmd") || ext == std::ffi::OsStr::new("md") { |
| if stderr.is_some() && stderr != Some(&crate::File::Text("".into())) { |
| panic!("stderr should have been merged: {:?}", stderr); |
| } |
| if let (Some(id), Some(stdout)) = (id, stdout) { |
| let step = self |
| .steps |
| .iter() |
| .find(|s| s.id.as_deref() == Some(id)) |
| .expect("id is valid"); |
| let line_nums = step |
| .expected_stdout_source |
| .clone() |
| .expect("always present for .trycmd"); |
| let mut stdout = stdout |
| .as_str() |
| .expect("already converted to Text") |
| .to_owned(); |
| // Add back trailing newline removed when parsing |
| stdout.push('\n'); |
| let mut raw = crate::File::read_from(path, Some(false))?; |
| raw.replace_lines(line_nums, &stdout)?; |
| raw.write_to(path)?; |
| } |
| } else { |
| return Err(format!("Unsupported extension: {}", ext.to_string_lossy()).into()); |
| } |
| } else { |
| return Err("No extension".into()); |
| } |
| |
| Ok(()) |
| } |
| |
| fn parse_trycmd(s: &str) -> Result<Self, crate::Error> { |
| let mut steps = Vec::new(); |
| |
| let mut lines: VecDeque<_> = crate::lines::LinesWithTerminator::new(s) |
| .enumerate() |
| .map(|(i, l)| (i + 1, l)) |
| .collect(); |
| 'outer: loop { |
| while let Some((_, line)) = lines.pop_front() { |
| if let Some(raw) = line.trim().strip_prefix("```") { |
| if raw.is_empty() { |
| // Assuming a trycmd block |
| break; |
| } else { |
| let mut info = raw.split(','); |
| let lang = info.next().unwrap(); |
| match lang { |
| "trycmd" | "console" => { |
| if info.any(|i| i == "ignore") { |
| debug!("ignore from infostring: {:?}", info); |
| } else { |
| break; |
| } |
| } |
| _ => { |
| debug!("ignore from lang: {:?}", lang); |
| } |
| } |
| } |
| |
| // Irrelevant block, consume to end |
| while let Some((_, line)) = lines.pop_front() { |
| if line.starts_with("```") { |
| continue 'outer; |
| } |
| } |
| } |
| } |
| |
| 'code: loop { |
| let mut cmdline = Vec::new(); |
| let mut expected_status = Some(CommandStatus::Success); |
| let mut stdout = String::new(); |
| let cmd_start; |
| let mut stdout_start; |
| |
| if let Some((line_num, line)) = lines.pop_front() { |
| if let Some(raw) = line.strip_prefix("$ ") { |
| cmdline.extend(shlex::Shlex::new(raw.trim())); |
| cmd_start = line_num; |
| stdout_start = line_num + 1; |
| } else { |
| return Err( |
| format!("Expected `$` on line {}, got `{}`", line_num, line).into() |
| ); |
| } |
| } else { |
| break 'outer; |
| } |
| while let Some((line_num, line)) = lines.pop_front() { |
| if let Some(raw) = line.strip_prefix("> ") { |
| cmdline.extend(shlex::Shlex::new(raw.trim())); |
| stdout_start = line_num + 1; |
| } else { |
| lines.push_front((line_num, line)); |
| break; |
| } |
| } |
| if let Some((line_num, line)) = lines.pop_front() { |
| if let Some(raw) = line.strip_prefix("? ") { |
| expected_status = Some(raw.trim().parse::<CommandStatus>()?); |
| stdout_start = line_num + 1; |
| } else { |
| lines.push_front((line_num, line)); |
| } |
| } |
| let mut post_stdout_start = stdout_start; |
| let mut block_done = false; |
| while let Some((line_num, line)) = lines.pop_front() { |
| if line.starts_with("$ ") { |
| lines.push_front((line_num, line)); |
| post_stdout_start = line_num; |
| break; |
| } else if line.starts_with("```") { |
| block_done = true; |
| post_stdout_start = line_num; |
| break; |
| } else { |
| stdout.push_str(line); |
| post_stdout_start = line_num + 1; |
| } |
| } |
| if stdout.ends_with('\n') { |
| // Last newline is for formatting purposes so tests can verify cases without a |
| // trailing newline. |
| stdout.pop(); |
| } |
| |
| let mut env = Env::default(); |
| |
| let bin = loop { |
| if cmdline.is_empty() { |
| return Err(format!("No bin specified on line {}", cmd_start).into()); |
| } |
| let next = cmdline.remove(0); |
| if let Some((key, value)) = next.split_once('=') { |
| env.add.insert(key.to_owned(), value.to_owned()); |
| } else { |
| break next; |
| } |
| }; |
| let step = Step { |
| id: Some(cmd_start.to_string()), |
| bin: Some(Bin::Name(bin)), |
| args: cmdline, |
| env, |
| stdin: None, |
| stderr_to_stdout: true, |
| expected_status, |
| expected_stdout_source: Some(stdout_start..post_stdout_start), |
| expected_stdout: Some(crate::File::Text(stdout)), |
| expected_stderr_source: None, |
| expected_stderr: None, |
| binary: false, |
| timeout: None, |
| }; |
| steps.push(step); |
| if block_done { |
| break 'code; |
| } |
| } |
| } |
| |
| Ok(Self { |
| steps, |
| ..Default::default() |
| }) |
| } |
| } |
| |
| fn overwrite_toml_output( |
| path: &std::path::Path, |
| _id: Option<&str>, |
| output: Option<&crate::File>, |
| output_ext: &str, |
| output_field: &str, |
| ) -> Result<(), crate::Error> { |
| if let Some(output) = output { |
| let output_path = path.with_extension(output_ext); |
| if output_path.exists() { |
| output.write_to(&output_path)?; |
| } else { |
| match output { |
| crate::File::Binary(_) => { |
| output.write_to(&output_path)?; |
| |
| let raw = std::fs::read_to_string(path) |
| .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; |
| let mut doc = raw |
| .parse::<toml_edit::Document>() |
| .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; |
| doc[output_field] = toml_edit::Item::None; |
| std::fs::write(path, doc.to_string()) |
| .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; |
| } |
| crate::File::Text(output) => { |
| let raw = std::fs::read_to_string(path) |
| .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; |
| let mut doc = raw |
| .parse::<toml_edit::Document>() |
| .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; |
| if let Some(output_value) = doc.get_mut(output_field) { |
| *output_value = toml_edit::value(output); |
| } |
| std::fs::write(path, doc.to_string()) |
| .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; |
| } |
| } |
| } |
| } |
| |
| Ok(()) |
| } |
| |
| impl std::str::FromStr for TryCmd { |
| type Err = crate::Error; |
| |
| fn from_str(s: &str) -> Result<Self, Self::Err> { |
| Self::parse_trycmd(s) |
| } |
| } |
| |
| impl From<OneShot> for TryCmd { |
| fn from(other: OneShot) -> Self { |
| let OneShot { |
| bin, |
| args, |
| env, |
| stdin, |
| stdout, |
| stderr, |
| stderr_to_stdout, |
| status, |
| binary, |
| timeout, |
| fs, |
| } = other; |
| Self { |
| steps: vec![Step { |
| id: None, |
| bin, |
| args: args.into_vec(), |
| env, |
| stdin: stdin.map(crate::File::Text), |
| stderr_to_stdout, |
| expected_status: status, |
| expected_stdout_source: None, |
| expected_stdout: stdout.map(crate::File::Text), |
| expected_stderr_source: None, |
| expected_stderr: stderr.map(crate::File::Text), |
| binary, |
| timeout, |
| }], |
| fs, |
| } |
| } |
| } |
| |
| #[derive(Clone, Default, Debug, PartialEq, Eq)] |
| pub(crate) struct Step { |
| pub(crate) id: Option<String>, |
| pub(crate) bin: Option<Bin>, |
| pub(crate) args: Vec<String>, |
| pub(crate) env: Env, |
| pub(crate) stdin: Option<crate::File>, |
| pub(crate) stderr_to_stdout: bool, |
| pub(crate) expected_status: Option<CommandStatus>, |
| pub(crate) expected_stdout_source: Option<std::ops::Range<usize>>, |
| pub(crate) expected_stdout: Option<crate::File>, |
| pub(crate) expected_stderr_source: Option<std::ops::Range<usize>>, |
| pub(crate) expected_stderr: Option<crate::File>, |
| pub(crate) binary: bool, |
| pub(crate) timeout: Option<std::time::Duration>, |
| } |
| |
| impl Step { |
| pub(crate) fn to_command( |
| &self, |
| cwd: Option<&std::path::Path>, |
| ) -> Result<std::process::Command, crate::Error> { |
| let bin = match &self.bin { |
| Some(Bin::Path(path)) => Ok(path.clone()), |
| Some(Bin::Name(name)) => Err(format!("Unknown bin.name = {}", name).into()), |
| Some(Bin::Error(err)) => Err(err.clone()), |
| None => Err("No bin specified".into()), |
| }?; |
| if !bin.exists() { |
| return Err(format!("Bin doesn't exist: {}", bin.display()).into()); |
| } |
| |
| let mut cmd = std::process::Command::new(bin); |
| cmd.args(&self.args); |
| if let Some(cwd) = cwd { |
| cmd.current_dir(cwd); |
| } |
| self.env.apply(&mut cmd); |
| |
| Ok(cmd) |
| } |
| |
| pub(crate) fn to_output( |
| &self, |
| cwd: Option<&std::path::Path>, |
| ) -> Result<std::process::Output, crate::Error> { |
| let mut cmd = self.to_command(cwd)?; |
| |
| if self.stderr_to_stdout { |
| cmd.stdin(std::process::Stdio::piped()); |
| let (mut reader, writer) = os_pipe::pipe().map_err(|e| e.to_string())?; |
| let writer_clone = writer.try_clone().map_err(|e| e.to_string())?; |
| cmd.stdout(writer); |
| cmd.stderr(writer_clone); |
| let child = cmd.spawn().map_err(|e| e.to_string())?; |
| |
| // Avoid a deadlock! This parent process is still holding open pipe |
| // writers (inside the Command object), and we have to close those |
| // before we read. Here we do this by dropping the Command object. |
| drop(cmd); |
| |
| let mut output = crate::wait_with_input_output(child, self.stdin(), self.timeout) |
| .map_err(|e| e.to_string())?; |
| assert!(output.stdout.is_empty()); |
| assert!(output.stderr.is_empty()); |
| reader |
| .read_to_end(&mut output.stdout) |
| .map_err(|e| e.to_string())?; |
| Ok(output) |
| } else { |
| cmd.stdin(std::process::Stdio::piped()); |
| cmd.stdout(std::process::Stdio::piped()); |
| cmd.stderr(std::process::Stdio::piped()); |
| let child = cmd.spawn().map_err(|e| e.to_string())?; |
| crate::wait_with_input_output(child, self.stdin(), self.timeout) |
| .map_err(|e| e.to_string().into()) |
| } |
| } |
| |
| pub(crate) fn expected_status(&self) -> CommandStatus { |
| self.expected_status.unwrap_or_default() |
| } |
| |
| pub(crate) fn stdin(&self) -> Option<&[u8]> { |
| self.stdin.as_ref().map(|f| f.as_bytes()) |
| } |
| } |
| |
| /// Top-level data in `cmd.toml` files |
| #[derive(Clone, Default, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] |
| #[serde(rename_all = "kebab-case")] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| pub struct OneShot { |
| pub(crate) bin: Option<Bin>, |
| #[serde(default)] |
| pub(crate) args: Args, |
| #[serde(default)] |
| pub(crate) env: Env, |
| #[serde(default)] |
| pub(crate) stdin: Option<String>, |
| #[serde(default)] |
| pub(crate) stdout: Option<String>, |
| #[serde(default)] |
| pub(crate) stderr: Option<String>, |
| #[serde(default)] |
| pub(crate) stderr_to_stdout: bool, |
| pub(crate) status: Option<CommandStatus>, |
| #[serde(default)] |
| pub(crate) binary: bool, |
| #[serde(default)] |
| #[serde(deserialize_with = "humantime_serde::deserialize")] |
| pub(crate) timeout: Option<std::time::Duration>, |
| #[serde(default)] |
| pub(crate) fs: Filesystem, |
| } |
| |
| impl OneShot { |
| fn parse_toml(s: &str) -> Result<Self, crate::Error> { |
| toml_edit::de::from_str(s).map_err(|e| e.to_string().into()) |
| } |
| } |
| |
| #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| #[serde(untagged)] |
| pub(crate) enum Args { |
| Joined(JoinedArgs), |
| Split(Vec<String>), |
| } |
| |
| impl Args { |
| fn new() -> Self { |
| Self::Split(Default::default()) |
| } |
| |
| fn as_slice(&self) -> &[String] { |
| match self { |
| Self::Joined(j) => j.inner.as_slice(), |
| Self::Split(v) => v.as_slice(), |
| } |
| } |
| |
| fn into_vec(self) -> Vec<String> { |
| match self { |
| Self::Joined(j) => j.inner, |
| Self::Split(v) => v, |
| } |
| } |
| } |
| |
| impl Default for Args { |
| fn default() -> Self { |
| Self::new() |
| } |
| } |
| |
| impl std::ops::Deref for Args { |
| type Target = [String]; |
| |
| fn deref(&self) -> &Self::Target { |
| self.as_slice() |
| } |
| } |
| |
| #[derive(Clone, Default, Debug, PartialEq, Eq)] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| pub(crate) struct JoinedArgs { |
| inner: Vec<String>, |
| } |
| |
| impl JoinedArgs { |
| #[cfg(test)] |
| pub(crate) fn from_vec(inner: Vec<String>) -> Self { |
| JoinedArgs { inner } |
| } |
| |
| #[allow(clippy::inherent_to_string_shadow_display)] |
| fn to_string(&self) -> String { |
| shlex::join(self.inner.iter().map(|s| s.as_str())) |
| } |
| } |
| |
| impl std::str::FromStr for JoinedArgs { |
| type Err = std::convert::Infallible; |
| |
| fn from_str(s: &str) -> Result<Self, Self::Err> { |
| let inner = shlex::Shlex::new(s).collect(); |
| Ok(Self { inner }) |
| } |
| } |
| |
| impl std::fmt::Display for JoinedArgs { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| self.to_string().fmt(f) |
| } |
| } |
| |
| impl<'de> serde::de::Deserialize<'de> for JoinedArgs { |
| fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |
| where |
| D: serde::de::Deserializer<'de>, |
| { |
| let s = String::deserialize(deserializer)?; |
| std::str::FromStr::from_str(&s).map_err(serde::de::Error::custom) |
| } |
| } |
| |
| impl serde::ser::Serialize for JoinedArgs { |
| fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> |
| where |
| S: serde::ser::Serializer, |
| { |
| serializer.serialize_str(&self.to_string()) |
| } |
| } |
| |
| /// Describe command's the filesystem context |
| #[derive(Clone, Default, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] |
| #[serde(rename_all = "kebab-case")] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| pub struct Filesystem { |
| pub(crate) cwd: Option<std::path::PathBuf>, |
| /// Sandbox base |
| pub(crate) base: Option<std::path::PathBuf>, |
| pub(crate) sandbox: Option<bool>, |
| } |
| |
| impl Filesystem { |
| pub(crate) fn sandbox(&self) -> bool { |
| self.sandbox.unwrap_or_default() |
| } |
| |
| pub(crate) fn rel_cwd(&self) -> Result<&std::path::Path, crate::Error> { |
| if let (Some(orig_cwd), Some(orig_base)) = (self.cwd.as_deref(), self.base.as_deref()) { |
| let rel_cwd = orig_cwd.strip_prefix(orig_base).map_err(|_| { |
| crate::Error::new(format!( |
| "fs.cwd ({}) must be within fs.base ({})", |
| orig_cwd.display(), |
| orig_base.display() |
| )) |
| })?; |
| Ok(rel_cwd) |
| } else { |
| Ok(std::path::Path::new("")) |
| } |
| } |
| } |
| |
| /// Describe command's environment |
| #[derive(Clone, Default, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] |
| #[serde(rename_all = "kebab-case")] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| pub struct Env { |
| #[serde(default)] |
| pub(crate) inherit: Option<bool>, |
| #[serde(default)] |
| pub(crate) add: BTreeMap<String, String>, |
| #[serde(default)] |
| pub(crate) remove: Vec<String>, |
| } |
| |
| impl Env { |
| pub(crate) fn update(&mut self, other: &Self) { |
| if self.inherit.is_none() { |
| self.inherit = other.inherit; |
| } |
| self.add |
| .extend(other.add.iter().map(|(k, v)| (k.clone(), v.clone()))); |
| self.remove.extend(other.remove.iter().cloned()); |
| } |
| |
| pub(crate) fn apply(&self, command: &mut std::process::Command) { |
| if !self.inherit() { |
| command.env_clear(); |
| } |
| for remove in &self.remove { |
| command.env_remove(&remove); |
| } |
| command.envs(&self.add); |
| } |
| |
| pub(crate) fn inherit(&self) -> bool { |
| self.inherit.unwrap_or(true) |
| } |
| } |
| |
| /// Target under test |
| #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] |
| #[serde(rename_all = "kebab-case")] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| pub enum Bin { |
| Path(std::path::PathBuf), |
| Name(String), |
| #[serde(skip)] |
| Error(crate::Error), |
| } |
| |
| impl From<std::path::PathBuf> for Bin { |
| fn from(other: std::path::PathBuf) -> Self { |
| Self::Path(other) |
| } |
| } |
| |
| impl<'a> From<&'a std::path::PathBuf> for Bin { |
| fn from(other: &'a std::path::PathBuf) -> Self { |
| Self::Path(other.clone()) |
| } |
| } |
| |
| impl<'a> From<&'a std::path::Path> for Bin { |
| fn from(other: &'a std::path::Path) -> Self { |
| Self::Path(other.to_owned()) |
| } |
| } |
| |
| impl<P, E> From<Result<P, E>> for Bin |
| where |
| P: Into<Bin>, |
| E: std::fmt::Display, |
| { |
| fn from(other: Result<P, E>) -> Self { |
| match other { |
| Ok(path) => path.into(), |
| Err(err) => { |
| let err = crate::Error::new(err.to_string()); |
| Bin::Error(err) |
| } |
| } |
| } |
| } |
| |
| /// Expected status for command |
| #[derive(Copy, Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] |
| #[serde(rename_all = "kebab-case")] |
| #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] |
| pub enum CommandStatus { |
| Success, |
| Failed, |
| Interrupted, |
| Skipped, |
| Code(i32), |
| } |
| |
| impl Default for CommandStatus { |
| fn default() -> Self { |
| CommandStatus::Success |
| } |
| } |
| |
| impl std::str::FromStr for CommandStatus { |
| type Err = crate::Error; |
| |
| fn from_str(s: &str) -> Result<Self, Self::Err> { |
| match s { |
| "success" => Ok(Self::Success), |
| "failed" => Ok(Self::Failed), |
| "interrupted" => Ok(Self::Interrupted), |
| "skipped" => Ok(Self::Skipped), |
| _ => s |
| .parse::<i32>() |
| .map(Self::Code) |
| .map_err(|_| crate::Error::new(format!("Expected an exit code, got {}", s))), |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod test { |
| use super::*; |
| |
| #[test] |
| fn parse_trycmd_command() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| expected_status: Some(CommandStatus::Success), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(4..4), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_command_line() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| args: vec!["arg1".into(), "arg with space".into()], |
| expected_status: Some(CommandStatus::Success), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(4..4), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd arg1 'arg with space' |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_multi_line() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| args: vec!["arg1".into(), "arg with space".into()], |
| expected_status: Some(CommandStatus::Success), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(5..5), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd arg1 |
| > 'arg with space' |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_env() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| env: Env { |
| add: IntoIterator::into_iter([ |
| ("KEY1".into(), "VALUE1".into()), |
| ("KEY2".into(), "VALUE2 with space".into()), |
| ]) |
| .collect(), |
| ..Default::default() |
| }, |
| expected_status: Some(CommandStatus::Success), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(4..4), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ KEY1=VALUE1 KEY2='VALUE2 with space' cmd |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_status() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| expected_status: Some(CommandStatus::Skipped), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(5..5), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd |
| ? skipped |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_status_code() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| expected_status: Some(CommandStatus::Code(-1)), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(5..5), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd |
| ? -1 |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_stdout() { |
| let expected = TryCmd { |
| steps: vec![Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd".into())), |
| expected_status: Some(CommandStatus::Success), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(4..6), |
| expected_stdout: Some(crate::File::Text("Hello World\n".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd |
| Hello World |
| |
| ```", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_multi_step() { |
| let expected = TryCmd { |
| steps: vec![ |
| Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("cmd1".into())), |
| expected_status: Some(CommandStatus::Code(1)), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(5..5), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }, |
| Step { |
| id: Some("5".into()), |
| bin: Some(Bin::Name("cmd2".into())), |
| expected_status: Some(CommandStatus::Success), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(6..6), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }, |
| ], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ cmd1 |
| ? 1 |
| $ cmd2 |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_trycmd_info_string() { |
| let expected = TryCmd { |
| steps: vec![ |
| Step { |
| id: Some("3".into()), |
| bin: Some(Bin::Name("bare-cmd".into())), |
| expected_status: Some(CommandStatus::Code(1)), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(5..5), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }, |
| Step { |
| id: Some("8".into()), |
| bin: Some(Bin::Name("trycmd-cmd".into())), |
| expected_status: Some(CommandStatus::Code(1)), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(10..10), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }, |
| Step { |
| id: Some("18".into()), |
| bin: Some(Bin::Name("console-cmd".into())), |
| expected_status: Some(CommandStatus::Code(1)), |
| stderr_to_stdout: true, |
| expected_stdout_source: Some(20..20), |
| expected_stdout: Some(crate::File::Text("".into())), |
| expected_stderr: None, |
| ..Default::default() |
| }, |
| ], |
| ..Default::default() |
| }; |
| let actual = TryCmd::parse_trycmd( |
| " |
| ``` |
| $ bare-cmd |
| ? 1 |
| ``` |
| |
| ```trycmd |
| $ trycmd-cmd |
| ? 1 |
| ``` |
| |
| ```sh |
| $ sh-cmd |
| ? 1 |
| ``` |
| |
| ```console |
| $ console-cmd |
| ? 1 |
| ``` |
| |
| ```ignore |
| $ rust-cmd1 |
| ? 1 |
| ``` |
| |
| ```trycmd,ignore |
| $ rust-cmd1 |
| ? 1 |
| ``` |
| |
| ```rust |
| $ rust-cmd1 |
| ? 1 |
| ``` |
| ", |
| ) |
| .unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_minimal() { |
| let expected = OneShot { |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml("").unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_minimal_env() { |
| let expected = OneShot { |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml("[env]").unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_bin_name() { |
| let expected = OneShot { |
| bin: Some(Bin::Name("cmd".into())), |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml("bin.name = 'cmd'").unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_bin_path() { |
| let expected = OneShot { |
| bin: Some(Bin::Path("/usr/bin/cmd".into())), |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml("bin.path = '/usr/bin/cmd'").unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_args_split() { |
| let expected = OneShot { |
| args: Args::Split(vec!["arg1".into(), "arg with space".into()]), |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml(r#"args = ["arg1", "arg with space"]"#).unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_args_joined() { |
| let expected = OneShot { |
| args: Args::Joined(JoinedArgs::from_vec(vec![ |
| "arg1".into(), |
| "arg with space".into(), |
| ])), |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml(r#"args = "arg1 'arg with space'""#).unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_status_success() { |
| let expected = OneShot { |
| status: Some(CommandStatus::Success), |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml("status = 'success'").unwrap(); |
| assert_eq!(expected, actual); |
| } |
| |
| #[test] |
| fn parse_toml_status_code() { |
| let expected = OneShot { |
| status: Some(CommandStatus::Code(42)), |
| ..Default::default() |
| }; |
| let actual = OneShot::parse_toml("status.code = 42").unwrap(); |
| assert_eq!(expected, actual); |
| } |
| } |