diff --git a/CHANGELOG.md b/CHANGELOG.md index 0859890..0b257dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## Unreleased +## 0.2.7 + - MSRV is raised to 1.63.0 +- Avoid using non-existant cfg in macros ## 0.2.6 diff --git a/Cargo.toml b/Cargo.toml index 8bdafe6..fbfaa3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,10 @@ name = "xshell" description = "Utilities for quick shell scripting in Rust" categories = ["development-tools::build-utils", "filesystem"] -version = "0.2.6" # also update xshell-macros/Cargo.toml and CHANGELOG.md +version = "0.2.7" # also update xshell-macros/Cargo.toml and CHANGELOG.md license = "MIT OR Apache-2.0" repository = "https://github.com/matklad/xshell" -authors = ["Aleksey Kladov "] +authors = ["Alex Kladov "] edition = "2021" rust-version = "1.63" @@ -14,7 +14,13 @@ exclude = [".github/", "bors.toml", "rustfmt.toml", "cbench", "mock_bin/"] [workspace] [dependencies] -xshell-macros = { version = "=0.2.6", path = "./xshell-macros" } +xshell-macros = { version = "=0.2.7", path = "./xshell-macros" } + +[target.'cfg(unix)'.dependencies] +libc = "0.2.155" + +[target.'cfg(windows)'.dependencies] +miow = "0.6.0" [dev-dependencies] anyhow = "1.0.56" diff --git a/README.md b/README.md index 8e11de1..aec78e6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ fn main() -> anyhow::Result<()> { let user = "matklad"; let repo = "xshell"; cmd!(sh, "git clone https://github.com/{user}/{repo}.git").run()?; - sh.change_dir(repo); + sh.set_current_dir(repo); let test_args = ["-Zunstable-options", "--report-time"]; cmd!(sh, "cargo test -- {test_args...}").run()?; @@ -29,7 +29,7 @@ fn main() -> anyhow::Result<()> { cmd!(sh, "git tag {version}").run()?; - let dry_run = if sh.var("CI").is_ok() { None } else { Some("--dry-run") }; + let dry_run = if sh.env_var("CI").is_ok() { None } else { Some("--dry-run") }; cmd!(sh, "cargo publish {dry_run...}").run()?; Ok(()) diff --git a/examples/ci.rs b/examples/ci.rs index 8166447..ac8b696 100644 --- a/examples/ci.rs +++ b/examples/ci.rs @@ -27,18 +27,18 @@ fn test(sh: &Shell) -> Result<()> { // running this, we've already compiled a bunch of stuff. Originally we tried to `rm -rf // .target`, but we also observed weird SIGKILL: 9 errors on mac. Perhaps its our self-removal? // Let's scope it only to linux (windows won't work, bc one can not remove oneself there). - if cfg!(linux) { + if cfg!(unix) { sh.remove_path("./target")?; } { let _s = Section::new("BUILD"); - cmd!(sh, "cargo test --workspace --no-run").run()?; + cmd!(sh, "cargo test --workspace --no-run").run_echo()?; } { let _s = Section::new("TEST"); - cmd!(sh, "cargo test --workspace").run()?; + cmd!(sh, "cargo test --workspace").run_echo()?; } Ok(()) } @@ -57,11 +57,11 @@ fn publish(sh: &Shell) -> Result<()> { if current_branch == "master" && !tag_exists { // Could also just use `CARGO_REGISTRY_TOKEN` environmental variable. - let token = sh.var("CRATES_IO_TOKEN").unwrap_or("DUMMY_TOKEN".to_string()); - cmd!(sh, "git tag v{version}").run()?; - cmd!(sh, "cargo publish --token {token} --package xshell-macros").run()?; - cmd!(sh, "cargo publish --token {token} --package xshell").run()?; - cmd!(sh, "git push --tags").run()?; + let token = sh.env_var("CRATES_IO_TOKEN").unwrap_or("DUMMY_TOKEN".to_string()); + cmd!(sh, "git tag v{version}").run_echo()?; + cmd!(sh, "cargo publish --token {token} --package xshell-macros").run_echo()?; + cmd!(sh, "cargo publish --token {token} --package xshell").run_echo()?; + cmd!(sh, "git push --tags").run_echo()?; } Ok(()) } diff --git a/examples/clone_and_publish.rs b/examples/clone_and_publish.rs index e18d9e2..7e971db 100644 --- a/examples/clone_and_publish.rs +++ b/examples/clone_and_publish.rs @@ -2,12 +2,12 @@ use xshell::{cmd, Shell}; fn main() -> anyhow::Result<()> { - let sh = Shell::new()?; + let mut sh = Shell::new()?; let user = "matklad"; let repo = "xshell"; cmd!(sh, "git clone https://github.com/{user}/{repo}.git").run()?; - sh.change_dir(repo); + sh.set_current_dir(repo); let test_args = ["-Zunstable-options", "--report-time"]; cmd!(sh, "cargo test -- {test_args...}").run()?; @@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> { cmd!(sh, "git tag {version}").run()?; - let dry_run = if sh.var("CI").is_ok() { None } else { Some("--dry-run") }; + let dry_run = if sh.env_var("CI").is_ok() { None } else { Some("--dry-run") }; cmd!(sh, "cargo publish {dry_run...}").run()?; Ok(()) diff --git a/src/error.rs b/src/error.rs index c35dcf4..99fcbf7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,14 @@ -use std::{env, ffi::OsString, fmt, io, path::PathBuf, process::ExitStatus, string::FromUtf8Error}; - -use crate::{Cmd, CmdData}; +use std::{ + env, + ffi::OsString, + fmt, io, + path::{Path, PathBuf}, + process::ExitStatus, + string::FromUtf8Error, + sync::Arc, +}; + +use crate::{Cmd, STREAM_SUFFIX_SIZE}; /// `Result` from std, with the error type defaulting to xshell's [`Error`]. pub type Result = std::result::Result; @@ -12,7 +20,7 @@ pub struct Error { /// Note: this is intentionally not public. enum ErrorKind { - CurrentDir { err: io::Error, path: Option }, + CurrentDir { err: io::Error, path: Option> }, Var { err: env::VarError, var: OsString }, ReadFile { err: io::Error, path: PathBuf }, ReadDir { err: io::Error, path: PathBuf }, @@ -21,10 +29,7 @@ enum ErrorKind { HardLink { err: io::Error, src: PathBuf, dst: PathBuf }, CreateDir { err: io::Error, path: PathBuf }, RemovePath { err: io::Error, path: PathBuf }, - CmdStatus { cmd: CmdData, status: ExitStatus }, - CmdIo { err: io::Error, cmd: CmdData }, - CmdUtf8 { err: FromUtf8Error, cmd: CmdData }, - CmdStdin { err: io::Error, cmd: CmdData }, + Cmd(CmdError), } impl From for Error { @@ -34,6 +39,20 @@ impl From for Error { } } +struct CmdError { + cmd: Cmd, + kind: CmdErrorKind, + stdout: Vec, + stderr: Vec, +} + +pub(crate) enum CmdErrorKind { + Io(io::Error), + Utf8(FromUtf8Error), + Status(ExitStatus), + Timeout, +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &*self.kind { @@ -76,48 +95,74 @@ impl fmt::Display for Error { let path = path.display(); write!(f, "failed to remove path `{path}`: {err}") } - ErrorKind::CmdStatus { cmd, status } => match status.code() { - Some(code) => write!(f, "command exited with non-zero code `{cmd}`: {code}"), + ErrorKind::Cmd(cmd) => fmt::Display::fmt(cmd, f), + }?; + Ok(()) + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} +impl std::error::Error for Error {} + +impl fmt::Display for CmdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let nl = if (self.stdout.len() > 0 || self.stderr.len() > 0) + && !matches!(self.kind, CmdErrorKind::Utf8(_)) + { + "\n" + } else { + "" + }; + let cmd = &self.cmd; + match &self.kind { + CmdErrorKind::Status(status) => match status.code() { + Some(code) => write!(f, "command exited with non-zero code `{cmd}`: {code}{nl}")?, #[cfg(unix)] None => { use std::os::unix::process::ExitStatusExt; match status.signal() { - Some(sig) => write!(f, "command was terminated by a signal `{cmd}`: {sig}"), - None => write!(f, "command was terminated by a signal `{cmd}`"), + Some(sig) => { + write!(f, "command was terminated by a signal `{cmd}`: {sig}{nl}")? + } + None => write!(f, "command was terminated by a signal `{cmd}`{nl}")?, } } #[cfg(not(unix))] - None => write!(f, "command was terminated by a signal `{cmd}`"), + None => write!(f, "command was terminated by a signal `{cmd}`{nl}"), }, - ErrorKind::CmdIo { err, cmd } => { + CmdErrorKind::Utf8(err) => { + write!(f, "command produced invalid utf-8 `{cmd}`: {err}")?; + return Ok(()); + } + CmdErrorKind::Io(err) => { if err.kind() == io::ErrorKind::NotFound { - let prog = cmd.prog.display(); - write!(f, "command not found: `{prog}`") + let prog = self.cmd.prog.as_path().display(); + write!(f, "command not found: `{prog}`{nl}")?; } else { - write!(f, "io error when running command `{cmd}`: {err}") + write!(f, "io error when running command `{cmd}`: {err}{nl}")?; } } - ErrorKind::CmdUtf8 { err, cmd } => { - write!(f, "failed to decode output of command `{cmd}`: {err}") - } - ErrorKind::CmdStdin { err, cmd } => { - write!(f, "failed to write to stdin of command `{cmd}`: {err}") + CmdErrorKind::Timeout => { + write!(f, "command timed out `{cmd}`{nl}")?; } - }?; + } + if self.stdout.len() > 0 { + write!(f, "stdout suffix\n:{}\n", String::from_utf8_lossy(&self.stdout))?; + } + if self.stderr.len() > 0 { + write!(f, "stderr suffix:\n:{}\n", String::from_utf8_lossy(&self.stderr))?; + } Ok(()) } } -impl fmt::Debug for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(self, f) - } -} -impl std::error::Error for Error {} - /// `pub(crate)` constructors, visible only in this crate. impl Error { - pub(crate) fn new_current_dir(err: io::Error, path: Option) -> Error { + pub(crate) fn new_current_dir(err: io::Error, path: Option>) -> Error { ErrorKind::CurrentDir { err, path }.into() } @@ -153,24 +198,33 @@ impl Error { ErrorKind::RemovePath { err, path }.into() } - pub(crate) fn new_cmd_status(cmd: &Cmd<'_>, status: ExitStatus) -> Error { - let cmd = cmd.data.clone(); - ErrorKind::CmdStatus { cmd, status }.into() - } - - pub(crate) fn new_cmd_io(cmd: &Cmd<'_>, err: io::Error) -> Error { - let cmd = cmd.data.clone(); - ErrorKind::CmdIo { err, cmd }.into() - } + pub(crate) fn new_cmd( + cmd: &Cmd, + kind: CmdErrorKind, + mut stdout: Vec, + mut stderr: Vec, + ) -> Error { + // Try to determine whether the command failed because the current + // directory does not exist. Return an appropriate error in such a + // case. + if let CmdErrorKind::Io(err) = &kind { + if err.kind() == io::ErrorKind::NotFound { + if let Err(err) = cmd.sh.cwd.metadata() { + return Error::new_current_dir(err, Some(cmd.sh.cwd.clone())); + } + } + } - pub(crate) fn new_cmd_utf8(cmd: &Cmd<'_>, err: FromUtf8Error) -> Error { - let cmd = cmd.data.clone(); - ErrorKind::CmdUtf8 { err, cmd }.into() - } + fn trim(xs: &mut Vec, size: usize) { + if xs.len() > size { + xs.drain(..xs.len() - size); + } + } - pub(crate) fn new_cmd_stdin(cmd: &Cmd<'_>, err: io::Error) -> Error { - let cmd = cmd.data.clone(); - ErrorKind::CmdStdin { err, cmd }.into() + let cmd = cmd.clone(); + trim(&mut stdout, STREAM_SUFFIX_SIZE); + trim(&mut stderr, STREAM_SUFFIX_SIZE); + ErrorKind::Cmd(CmdError { cmd, kind, stdout, stderr }).into() } } diff --git a/src/exec.rs b/src/exec.rs new file mode 100644 index 0000000..1476a2f --- /dev/null +++ b/src/exec.rs @@ -0,0 +1,160 @@ +//! Executes the process, feeding it stdin, reading stdout/stderr (up to the specified limit), and +//! imposing a deadline. +//! +//! This really is quite unhappy code, wasting whopping four threads for the task _and_ including a +//! sleepy loop! This is not system programming, just a pile of work-around. What is my excuse? +//! +//! The _right_ way to do this is of course by using evened syscalls --- concurrently await stream +//! io, timeout, and process termination. The _first_ two kinda-sorta solvable, see the `read2` +//! module in Cargo. For unix, we through fds into a epoll via libc, for windows we use completion +//! ports via miow. That's some ugly platform-specific code and two dependencies, but doable. +//! +//! Both poll and completion ports naturally have a timeout, so that's doable as well. However, +//! tying process termination into the same epoll is not really possible. One can use pidfd's on +//! Linux, but that's even _more_ platform specific code, and there are other UNIXes. +//! +//! Given that, if I were to use evented IO, I'd have to pull dependencies, write a bunch of +//! platform-specific glue code _and_ write some from scratch things for waiting, I decided to stick +//! to blocking APIs. +//! +//! This should be easy, right? Just burn a thread per asynchronous operation! Well, the `wait` +//! strikes again! Both `.kill` and `.wait` require `&mut Child`, so you can't wait on the main +//! thread, and `.kill` from the timeout thread. One can think that that's just deficiency of Rust +//! API, but, now, this is again just UNIX. Both kill and wait operate on pids, and a pid can be +//! re-used immediately after wait. As far as I understand, this is a race condition you can't lock +//! your way out of. Hence the sleepy loop in wait_deadline. + +use std::{ + collections::VecDeque, + io::{self, Read, Write}, + process::{Child, ExitStatus, Stdio}, + time::{Duration, Instant}, +}; + +#[derive(Default)] +pub(crate) struct ExecResult { + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, + pub(crate) status: Option, + pub(crate) error: Option, +} + +pub(crate) fn wait_deadline( + child: &mut Child, + deadline: Option, +) -> io::Result { + let Some(deadline) = deadline else { + return child.wait(); + }; + + let mut sleep_ms = 1; + let sleep_ms_max = 64; + loop { + match child.try_wait()? { + Some(status) => return Ok(status), + None => {} + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::ErrorKind::TimedOut.into()); + } + std::thread::sleep(Duration::from_millis(sleep_ms)); + sleep_ms = std::cmp::min(sleep_ms * 2, sleep_ms_max); + } +} + +pub(crate) fn exec( + mut command: std::process::Command, + stdin_contents: Option<&[u8]>, + stdout_limit: Option, + stderr_limit: Option, + deadline: Option, +) -> ExecResult { + let mut result = ExecResult::default(); + command.stdin(if stdin_contents.is_some() { Stdio::inherit() } else { Stdio::null() }); + command.stdout(Stdio::piped()); + command.stdout(Stdio::piped()); + let mut child = match command.spawn() { + Ok(it) => it, + Err(err) => { + result.error = Some(err); + return result; + } + }; + + let stdin = child.stdin.take(); + let mut in_error = Ok(()); + + let mut stdout = child.stdout.take().unwrap(); + let mut out_deque = VecDeque::new(); + let mut out_error = Ok(()); + + let mut stderr = child.stderr.take().unwrap(); + let mut err_deque = VecDeque::new(); + let mut err_error = Ok(()); + + let status = std::thread::scope(|scope| { + if let Some(stdin_contents) = stdin_contents { + scope.spawn(|| in_error = stdin.unwrap().write_all(stdin_contents)); + } + scope.spawn(|| { + out_error = (|| { + let mut buffer = [0u8; 4096]; + loop { + let n = stdout.read(&mut buffer)?; + if n == 0 { + return Ok(()); + } + out_deque.extend(buffer[0..n].iter().copied()); + let excess = out_deque.len().saturating_sub(stdout_limit.unwrap_or(usize::MAX)); + if excess > 0 { + out_deque.drain(..excess); + } + } + })() + }); + scope.spawn(|| { + err_error = (|| { + let mut buffer = [0u8; 4096]; + loop { + let n = stderr.read(&mut buffer)?; + if n == 0 { + return Ok(()); + } + err_deque.extend(buffer[0..n].iter().copied()); + let excess = err_deque.len().saturating_sub(stderr_limit.unwrap_or(usize::MAX)); + if excess > 0 { + err_deque.drain(..excess); + } + } + })() + }); + + wait_deadline(&mut child, deadline) + }); + + if let Err(err) = err_error { + result.error = err; + } + + if let Err(err) = out_error { + result.error = err; + } + + if let Err(err) = in_error { + if err.kind() != io::ErrorKind::BrokenPipe { + result.error = Some(err); + } + } + + match status { + Ok(status) => result.status = Some(status), + Err(err) => result.error = Some(err), + } + + result.stdout = out_deque.into(); + result.stderr = err_deque.into(); + + result +} diff --git a/src/lib.rs b/src/lib.rs index de213f0..47161cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,9 +116,9 @@ //! Next, `cd` into the folder you have just cloned: //! //! ```no_run -//! # use xshell::{Shell, cmd}; let sh = Shell::new().unwrap(); +//! # use xshell::{Shell, cmd}; let mut sh = Shell::new().unwrap(); //! # let repo = "xshell"; -//! sh.change_dir(repo); +//! sh.set_current_dir(repo); //! ``` //! //! Each instance of [`Shell`] has a current directory, which is independent of @@ -180,7 +180,7 @@ //! //! ```no_run //! # use xshell::{Shell, cmd}; let sh = Shell::new().unwrap(); -//! let dry_run = if sh.var("CI").is_ok() { None } else { Some("--dry-run") }; +//! let dry_run = if sh.env_var("CI").is_ok() { None } else { Some("--dry-run") }; //! cmd!(sh, "cargo publish {dry_run...}").run()?; //! # Ok::<(), xshell::Error>(()) //! ``` @@ -191,12 +191,12 @@ //! use xshell::{cmd, Shell}; //! //! fn main() -> anyhow::Result<()> { -//! let sh = Shell::new()?; +//! let mut sh = Shell::new()?; //! //! let user = "matklad"; //! let repo = "xshell"; //! cmd!(sh, "git clone https://github.com/{user}/{repo}.git").run()?; -//! sh.change_dir(repo); +//! sh.set_current_dir(repo); //! //! let test_args = ["-Zunstable-options", "--report-time"]; //! cmd!(sh, "cargo test -- {test_args...}").run()?; @@ -210,7 +210,7 @@ //! //! cmd!(sh, "git tag {version}").run()?; //! -//! let dry_run = if sh.var("CI").is_ok() { None } else { Some("--dry-run") }; +//! let dry_run = if sh.env_var("CI").is_ok() { None } else { Some("--dry-run") }; //! cmd!(sh, "cargo publish {dry_run...}").run()?; //! //! Ok(()) @@ -276,25 +276,33 @@ #![deny(missing_docs)] #![deny(rust_2018_idioms)] +mod exec; mod error; use std::{ - cell::RefCell, collections::HashMap, env::{self, current_dir, VarError}, ffi::{OsStr, OsString}, - fmt, fs, - io::{self, ErrorKind, Write}, + fmt::{self}, + fs, + io::{self, ErrorKind}, mem, path::{Path, PathBuf}, - process::{Command, ExitStatus, Output, Stdio}, - sync::atomic::{AtomicUsize, Ordering}, + process::{Command, Output, Stdio}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, }; pub use crate::error::{Error, Result}; +use error::CmdErrorKind; #[doc(hidden)] pub use xshell_macros::__cmd; +const STREAM_SUFFIX_SIZE: usize = 128 * 1024; // 128KiB + /// Constructs a [`Cmd`] from the given string. /// /// # Examples @@ -345,7 +353,7 @@ pub use xshell_macros::__cmd; #[macro_export] macro_rules! cmd { ($sh:expr, $cmd:literal) => {{ - #[cfg(trick_rust_analyzer_into_highlighting_interpolated_bits)] + #[cfg(any())] // Trick rust analyzer into highlighting interpolated bits format_args!($cmd); let f = |prog| $sh.cmd(prog); let cmd: $crate::Cmd = $crate::__cmd!(f $cmd); @@ -372,7 +380,7 @@ macro_rules! cmd { /// use xshell::{cmd, Shell}; /// /// let sh = Shell::new()?; -/// let _d = sh.push_dir("./target"); +/// let sh = sh.with_current_dir("./target"); /// let cwd = sh.current_dir(); /// cmd!(sh, "echo current dir is {cwd}").run()?; /// @@ -382,59 +390,50 @@ macro_rules! cmd { /// ``` #[derive(Debug, Clone)] pub struct Shell { - cwd: RefCell, - env: RefCell>, + cwd: Arc, + env: Arc, Arc>>, } -impl std::panic::UnwindSafe for Shell {} -impl std::panic::RefUnwindSafe for Shell {} - +/// You can use `Shell` in a tree manner by cloning the shell and modifying the `cwd`/`env` as needed. impl Shell { /// Creates a new [`Shell`]. /// /// Fails if [`std::env::current_dir`] returns an error. pub fn new() -> Result { let cwd = current_dir().map_err(|err| Error::new_current_dir(err, None))?; - let cwd = RefCell::new(cwd); - let env = RefCell::new(HashMap::new()); - Ok(Shell { cwd, env }) + Ok(Shell { cwd: cwd.into(), env: Default::default() }) } - // region:env /// Returns the working directory for this [`Shell`]. /// /// All relative paths are interpreted relative to this directory, rather /// than [`std::env::current_dir`]. #[doc(alias = "pwd")] - pub fn current_dir(&self) -> PathBuf { - self.cwd.borrow().clone() + pub fn current_dir(&self) -> &Path { + self.cwd.as_ref() } /// Changes the working directory for this [`Shell`]. /// /// Note that this doesn't affect [`std::env::current_dir`]. - #[doc(alias = "pwd")] - pub fn change_dir>(&self, dir: P) { - self._change_dir(dir.as_ref()) - } - fn _change_dir(&self, dir: &Path) { - let dir = self.path(dir); - *self.cwd.borrow_mut() = dir; + #[doc(alias = "cd")] + pub fn set_current_dir(&mut self, path: impl AsRef) { + fn inner(sh: &mut Shell, path: &OsStr) { + sh.cwd = sh.cwd.join(path).into(); + } + inner(self, path.as_ref().as_os_str()); } - /// Temporary changes the working directory of this [`Shell`]. - /// - /// Returns a RAII guard which reverts the working directory to the old - /// value when dropped. + /// Returns a new [`Shell`] with the working directory set to `path`. /// /// Note that this doesn't affect [`std::env::current_dir`]. #[doc(alias = "pushd")] - pub fn push_dir>(&self, path: P) -> PushDir<'_> { - self._push_dir(path.as_ref()) - } - fn _push_dir(&self, path: &Path) -> PushDir<'_> { - let path = self.path(path); - PushDir::new(self, path) + #[must_use] + pub fn with_current_dir(&self, path: impl AsRef) -> Shell { + fn inner(sh: &Shell, path: &OsStr) -> Shell { + Shell { cwd: sh.cwd.join(path).into(), env: sh.env.clone() } + } + inner(self, path.as_ref().as_os_str()) } /// Fetches the environmental variable `key` for this [`Shell`]. @@ -443,15 +442,17 @@ impl Shell { /// /// Environment of the [`Shell`] affects all commands spawned via this /// shell. - pub fn var>(&self, key: K) -> Result { - self._var(key.as_ref()) - } - fn _var(&self, key: &OsStr) -> Result { - match self._var_os(key) { - Some(it) => it.into_string().map_err(VarError::NotUnicode), - None => Err(VarError::NotPresent), + pub fn env_var(&self, key: impl AsRef) -> Result { + fn inner(sh: &Shell, key: &OsStr) -> Result { + let env_os = sh + .env_var_os(key) + .ok_or(VarError::NotPresent) + .map_err(|err| Error::new_var(err, key.to_os_string()))?; + env_os + .into_string() + .map_err(|value| Error::new_var(VarError::NotUnicode(value), key.to_os_string())) } - .map_err(|err| Error::new_var(err, key.to_os_string())) + inner(self, key.as_ref()) } /// Fetches the environmental variable `key` for this [`Shell`] as @@ -459,77 +460,65 @@ impl Shell { /// /// Environment of the [`Shell`] affects all commands spawned via this /// shell. - pub fn var_os>(&self, key: K) -> Option { - self._var_os(key.as_ref()) + pub fn env_var_os(&self, key: impl AsRef) -> Option { + fn inner(sh: &Shell, key: &OsStr) -> Option { + sh.env.get(key).map(OsString::from).or_else(|| env::var_os(key)) + } + inner(self, key.as_ref()) } - fn _var_os(&self, key: &OsStr) -> Option { - self.env.borrow().get(key).cloned().or_else(|| env::var_os(key)) + + /// Fetches the whole environment as a `(Key, Value)` iterator for this [`Shell`]. + /// + /// Returns an error if any of the variables are not utf8. + /// + /// Environment of the [`Shell`] affects all commands spawned via this + /// shell. + pub fn env_vars_os(&self) -> HashMap { + let mut result: HashMap = Default::default(); + result.extend(env::vars_os()); + result.extend(self.env.iter().map(|(k, v)| (OsString::from(k), OsString::from(v)))); + result } - /// Sets the value of `key` environment variable for this [`Shell`] to - /// `val`. + /// Sets the value of `key` environment variable for this [`Shell`] to `value`. /// /// Note that this doesn't affect [`std::env::var`]. - pub fn set_var, V: AsRef>(&self, key: K, val: V) { - self._set_var(key.as_ref(), val.as_ref()) - } - fn _set_var(&self, key: &OsStr, val: &OsStr) { - self.env.borrow_mut().insert(key.to_os_string(), val.to_os_string()); + pub fn set_env_var(&mut self, key: impl AsRef, value: impl AsRef) { + fn inner(sh: &mut Shell, key: &OsStr, value: &OsStr) { + Arc::make_mut(&mut sh.env).insert(key.into(), value.into()); + } + inner(self, key.as_ref(), value.as_ref()); } - /// Temporary sets the value of `key` environment variable for this - /// [`Shell`] to `val`. - /// - /// Returns a RAII guard which restores the old environment when dropped. + /// Returns a new [`Shell`] with environmental variable `key` set to `value`. /// /// Note that this doesn't affect [`std::env::var`]. - pub fn push_env, V: AsRef>(&self, key: K, val: V) -> PushEnv<'_> { - self._push_env(key.as_ref(), val.as_ref()) - } - fn _push_env(&self, key: &OsStr, val: &OsStr) -> PushEnv<'_> { - PushEnv::new(self, key.to_os_string(), val.to_os_string()) + pub fn with_env_var(&self, key: impl AsRef, value: impl AsRef) -> Shell { + fn inner(sh: &Shell, key: &OsStr, value: &OsStr) -> Shell { + let mut env = Arc::clone(&sh.env); + Arc::make_mut(&mut env).insert(key.into(), value.into()); + Shell { cwd: sh.cwd.clone(), env } + } + inner(self, key.as_ref(), value.as_ref()) } - // endregion:env - // region:fs - /// Read the entire contents of a file into a string. + /// Read an utf-8 encoded text file into string. #[doc(alias = "cat")] - pub fn read_file>(&self, path: P) -> Result { - self._read_file(path.as_ref()) - } - fn _read_file(&self, path: &Path) -> Result { - let path = self.path(path); - fs::read_to_string(&path).map_err(|err| Error::new_read_file(err, path)) - } - - /// Read the entire contents of a file into a vector of bytes. - pub fn read_binary_file>(&self, path: P) -> Result> { - self._read_binary_file(path.as_ref()) - } - fn _read_binary_file(&self, path: &Path) -> Result> { - let path = self.path(path); - fs::read(&path).map_err(|err| Error::new_read_file(err, path)) + pub fn read_file(&self, path: impl AsRef) -> Result { + fn inner(sh: &Shell, path: &Path) -> Result { + let path = sh.path(path); + fs::read_to_string(&path).map_err(|err| Error::new_read_file(err, path)) + } + inner(self, path.as_ref()) } - /// Returns a sorted list of paths directly contained in the directory at - /// `path`. - #[doc(alias = "ls")] - pub fn read_dir>(&self, path: P) -> Result> { - self._read_dir(path.as_ref()) - } - fn _read_dir(&self, path: &Path) -> Result> { - let path = self.path(path); - let mut res = Vec::new(); - || -> _ { - for entry in fs::read_dir(&path)? { - let entry = entry?; - res.push(entry.path()) - } - Ok(()) - }() - .map_err(|err| Error::new_read_dir(err, path))?; - res.sort(); - Ok(res) + /// Read a file into a vector of bytes. + pub fn read_binary_file(&self, path: impl AsRef) -> Result> { + fn inner(sh: &Shell, path: &Path) -> Result> { + let path = sh.path(path); + fs::read(&path).map_err(|err| Error::new_read_file(err, path)) + } + inner(self, path.as_ref()) } /// Write a slice as the entire contents of a file. @@ -537,60 +526,91 @@ impl Shell { /// This function will create the file and all intermediate directories if /// they don't exist. // TODO: probably want to make this an atomic rename write? - pub fn write_file, C: AsRef<[u8]>>(&self, path: P, contents: C) -> Result<()> { - self._write_file(path.as_ref(), contents.as_ref()) - } - fn _write_file(&self, path: &Path, contents: &[u8]) -> Result<()> { - let path = self.path(path); - if let Some(p) = path.parent() { - self.create_dir(p)?; + pub fn write_file(&self, path: impl AsRef, contents: impl AsRef<[u8]>) -> Result<()> { + fn inner(sh: &Shell, path: &Path, contents: &[u8]) -> Result<()> { + let path = sh.path(path); + if let Some(p) = path.parent() { + sh.create_dir(p)?; + } + fs::write(&path, contents).map_err(|err| Error::new_write_file(err, path)) } - fs::write(&path, contents).map_err(|err| Error::new_write_file(err, path)) + inner(self, path.as_ref(), contents.as_ref()) } - /// Copies `src` into `dst`. - /// - /// `src` must be a file, but `dst` need not be. If `dst` is an existing - /// directory, `src` will be copied into a file in the `dst` directory whose - /// name is same as that of `src`. - /// - /// Otherwise, `dst` is a file or does not exist, and `src` will be copied into - /// it. + /// Creates a `dst` file with the same contents as `src` #[doc(alias = "cp")] - pub fn copy_file, D: AsRef>(&self, src: S, dst: D) -> Result<()> { - self._copy_file(src.as_ref(), dst.as_ref()) - } - fn _copy_file(&self, src: &Path, dst: &Path) -> Result<()> { - let src = self.path(src); - let dst = self.path(dst); - let dst = dst.as_path(); - let mut _tmp; - let mut dst = dst; - if dst.is_dir() { - if let Some(file_name) = src.file_name() { - _tmp = dst.join(file_name); - dst = &_tmp; + pub fn copy_file_to_path( + &self, + src_file: impl AsRef, + dst_file: impl AsRef, + ) -> Result<()> { + fn inner(sh: &Shell, src: &Path, dst: &Path) -> Result<()> { + let src = sh.path(src); + let dst = sh.path(dst); + if let Some(p) = dst.parent() { + sh.create_dir(p)?; } + std::fs::copy(&src, &dst) + .map_err(|err| Error::new_copy_file(err, src.to_path_buf(), dst.to_path_buf()))?; + Ok(()) } - std::fs::copy(&src, dst) - .map_err(|err| Error::new_copy_file(err, src.to_path_buf(), dst.to_path_buf()))?; - Ok(()) + inner(self, src_file.as_ref(), dst_file.as_ref()) + } + + /// Creates a file in `dst` directory with the same name and contents as `src`. + #[doc(alias = "cp")] + pub fn copy_file_to_dir( + &self, + src_file: impl AsRef, + dst_dir: impl AsRef, + ) -> Result<()> { + fn inner(sh: &Shell, src: &Path, dst: &Path) -> Result<()> { + let src = sh.path(src); + let dst = sh.path(dst); + let Some(file_name) = src.file_name() else { + return Err(Error::new_copy_file(io::ErrorKind::InvalidData.into(), src, dst)); + }; + sh.copy_file_to_path(&src, &dst.join(file_name)) + } + inner(self, src_file.as_ref(), dst_dir.as_ref()) } /// Hardlinks `src` to `dst`. #[doc(alias = "ln")] - pub fn hard_link, D: AsRef>(&self, src: S, dst: D) -> Result<()> { - self._hard_link(src.as_ref(), dst.as_ref()) + pub fn hard_link(&self, src: impl AsRef, dst: impl AsRef) -> Result<()> { + fn inner(sh: &Shell, src: &Path, dst: &Path) -> Result<()> { + let src = sh.path(src); + let dst = sh.path(dst); + fs::hard_link(&src, &dst).map_err(|err| Error::new_hard_link(err, src, dst)) + } + inner(self, src.as_ref(), dst.as_ref()) } - fn _hard_link(&self, src: &Path, dst: &Path) -> Result<()> { - let src = self.path(src); - let dst = self.path(dst); - fs::hard_link(&src, &dst).map_err(|err| Error::new_hard_link(err, src, dst)) + + /// Returns a sorted list of paths directly contained in the directory at `path`. + #[doc(alias = "ls")] + pub fn read_dir(&self, path: impl AsRef) -> Result> { + fn inner(sh: &Shell, path: &Path) -> Result> { + let path = sh.path(path); + let mut res = Vec::new(); + || -> _ { + for entry in fs::read_dir(&path)? { + let entry = entry?; + res.push(entry.path()) + } + Ok(()) + }() + .map_err(|err| Error::new_read_dir(err, path))?; + // Sort to ensure determinism, and ease debugging of downstream programs! + res.sort(); + Ok(res) + } + + inner(self, path.as_ref()) } - /// Creates the specified directory. + /// Ensures that the specified directory exist. /// - /// All intermediate directories will also be created. + /// All intermediate directories will also be created as needed. #[doc(alias("mkdir_p", "mkdir"))] pub fn create_dir>(&self, path: P) -> Result { self._create_dir(path.as_ref()) @@ -605,12 +625,11 @@ impl Shell { /// Creates an empty named world-readable temporary directory. /// - /// Returns a [`TempDir`] RAII guard with the path to the directory. When - /// dropped, the temporary directory and all of its contents will be - /// removed. + /// Returns a [`TempDir`] RAII guard with the path to the directory. When dropped, the temporary + /// directory and all of its contents will be removed. /// - /// Note that this is an **insecure method** -- any other process on the - /// system will be able to read the data. + /// Note that this is an **insecure method** -- any other process on the system will be able to + /// read the data. #[doc(alias = "mktemp")] pub fn create_temp_dir(&self) -> Result { let base = std::env::temp_dir(); @@ -618,105 +637,54 @@ impl Shell { static CNT: AtomicUsize = AtomicUsize::new(0); - let mut n_try = 0u32; + // TODO: once std gets random numbers, start with random u128 here. + let mut try_count = 0u32; loop { let cnt = CNT.fetch_add(1, Ordering::Relaxed); let path = base.join(format!("xshell-tmp-dir-{}", cnt)); match fs::create_dir(&path) { Ok(()) => return Ok(TempDir { path }), - Err(err) if n_try == 1024 => return Err(Error::new_create_dir(err, path)), - Err(_) => n_try += 1, + Err(err) if try_count == 1024 => return Err(Error::new_create_dir(err, path)), + Err(_) => try_count += 1, } } } /// Removes the file or directory at the given path. #[doc(alias("rm_rf", "rm"))] - pub fn remove_path>(&self, path: P) -> Result<()> { - self._remove_path(path.as_ref()) - } - fn _remove_path(&self, path: &Path) -> Result<(), Error> { - let path = self.path(path); - match path.metadata() { - Ok(meta) => if meta.is_dir() { remove_dir_all(&path) } else { fs::remove_file(&path) } - .map_err(|err| Error::new_remove_path(err, path)), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(Error::new_remove_path(err, path)), + pub fn remove_path(&self, path: impl AsRef) -> Result<()> { + fn inner(sh: &Shell, path: &Path) -> Result<(), Error> { + let path = sh.path(path); + match path.metadata() { + Ok(meta) => { + if meta.is_dir() { remove_dir_all(&path) } else { fs::remove_file(&path) } + .map_err(|err| Error::new_remove_path(err, path)) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(Error::new_remove_path(err, path)), + } } + inner(self, path.as_ref()) } /// Returns whether a file or directory exists at the given path. + /// + /// Be mindful of Time Of Check, Time Of Use (TOCTOU) errors -- often, it is better to attempt a + /// given operation and handle an error if a path doesn't exist, instead of trying to check + /// beforehand. #[doc(alias("stat"))] pub fn path_exists>(&self, path: P) -> bool { self.path(path.as_ref()).exists() } - // endregion:fs /// Creates a new [`Cmd`] that executes the given `program`. - pub fn cmd>(&self, program: P) -> Cmd<'_> { + pub fn cmd(&self, program: impl AsRef) -> Cmd { // TODO: path lookup? Cmd::new(self, program.as_ref()) } fn path(&self, p: &Path) -> PathBuf { - let cd = self.cwd.borrow(); - cd.join(p) - } -} - -/// RAII guard returned from [`Shell::push_dir`]. -/// -/// Dropping `PushDir` restores the working directory of the [`Shell`] to the -/// old value. -#[derive(Debug)] -#[must_use] -pub struct PushDir<'a> { - old_cwd: PathBuf, - shell: &'a Shell, -} - -impl<'a> PushDir<'a> { - fn new(shell: &'a Shell, path: PathBuf) -> PushDir<'a> { - PushDir { old_cwd: mem::replace(&mut *shell.cwd.borrow_mut(), path), shell } - } -} - -impl Drop for PushDir<'_> { - fn drop(&mut self) { - mem::swap(&mut *self.shell.cwd.borrow_mut(), &mut self.old_cwd) - } -} - -/// RAII guard returned from [`Shell::push_env`]. -/// -/// Dropping `PushEnv` restores the old value of the environmental variable. -#[derive(Debug)] -#[must_use] -pub struct PushEnv<'a> { - key: OsString, - old_value: Option, - shell: &'a Shell, -} - -impl<'a> PushEnv<'a> { - fn new(shell: &'a Shell, key: OsString, val: OsString) -> PushEnv<'a> { - let old_value = shell.env.borrow_mut().insert(key.clone(), val); - PushEnv { shell, key, old_value } - } -} - -impl Drop for PushEnv<'_> { - fn drop(&mut self) { - let mut env = self.shell.env.borrow_mut(); - let key = mem::take(&mut self.key); - match self.old_value.take() { - Some(value) => { - env.insert(key, value); - } - None => { - env.remove(&key); - } - } + self.cwd.join(p) } } @@ -737,50 +705,25 @@ impl Drop for PushEnv<'_> { /// let cmd = cmd!(sh, "git switch {branch}").quiet().run()?; /// # Ok::<(), xshell::Error>(()) /// ``` -#[derive(Debug)] +#[derive(Debug, Clone)] #[must_use] -pub struct Cmd<'a> { - shell: &'a Shell, - data: CmdData, -} - -#[derive(Debug, Default, Clone)] -struct CmdData { +pub struct Cmd { + sh: Shell, prog: PathBuf, args: Vec, - env_changes: Vec, + stdin_contents: Option>, + deadline: Option, ignore_status: bool, - quiet: bool, secret: bool, - stdin_contents: Option>, - ignore_stdout: bool, - ignore_stderr: bool, -} - -// We just store a list of functions to call on the `Command` — the alternative -// would require mirroring the logic that `std::process::Command` (or rather -// `sys_common::CommandEnvs`) uses, which is moderately complex, involves -// special-casing `PATH`, and plausibly could change. -#[derive(Debug, Clone)] -enum EnvChange { - Set(OsString, OsString), - Remove(OsString), - Clear, -} - -impl fmt::Display for Cmd<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(&self.data, f) - } } -impl fmt::Display for CmdData { +impl fmt::Display for Cmd { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.secret { return write!(f, ""); } - write!(f, "{}", self.prog.display())?; + write!(f, "{}", self.prog.as_path().display())?; for arg in &self.args { // TODO: this is potentially not copy-paste safe. let arg = arg.to_string_lossy(); @@ -794,258 +737,320 @@ impl fmt::Display for CmdData { } } -impl From> for Command { - fn from(cmd: Cmd<'_>) -> Command { +impl From for Command { + fn from(cmd: Cmd) -> Command { cmd.to_command() } } -impl<'a> Cmd<'a> { - fn new(shell: &'a Shell, prog: &Path) -> Cmd<'a> { - let mut data = CmdData::default(); - data.prog = prog.to_path_buf(); - Cmd { shell, data } +impl Cmd { + fn new(sh: &Shell, program: impl AsRef) -> Cmd { + fn inner(sh: &Shell, program: &Path) -> Cmd { + Cmd { + sh: sh.clone(), + prog: program.into(), + args: Vec::new(), + stdin_contents: None, + ignore_status: false, + deadline: None, + secret: false, + } + } + inner(sh, program.as_ref()) } - // region:builder - /// Adds an argument to this commands. - pub fn arg>(mut self, arg: P) -> Cmd<'a> { - self._arg(arg.as_ref()); + /// Adds an argument to this command. + pub fn arg(mut self, arg: impl AsRef) -> Cmd { + self.arg_inner(arg.as_ref()); self } - fn _arg(&mut self, arg: &OsStr) { - self.data.args.push(arg.to_owned()) + fn arg_inner(&mut self, arg: &OsStr) { + self.args.push(arg.to_owned()) } /// Adds all of the arguments to this command. - pub fn args(mut self, args: I) -> Cmd<'a> + pub fn args(mut self, args: I) -> Self where I: IntoIterator, I::Item: AsRef, { - args.into_iter().for_each(|it| self._arg(it.as_ref())); + args.into_iter().for_each(|it| self.arg_inner(it.as_ref())); self } #[doc(hidden)] - pub fn __extend_arg>(mut self, arg_fragment: P) -> Cmd<'a> { - self.___extend_arg(arg_fragment.as_ref()); - self - } - fn ___extend_arg(&mut self, arg_fragment: &OsStr) { - match self.data.args.last_mut() { - Some(last_arg) => last_arg.push(arg_fragment), - None => { - let mut prog = mem::take(&mut self.data.prog).into_os_string(); - prog.push(arg_fragment); - self.data.prog = prog.into(); + pub fn __extend_arg(mut self, arg_fragment: impl AsRef) -> Cmd { + fn inner(sh: &mut Cmd, arg_fragment: &OsStr) { + match sh.args.last_mut() { + Some(last_arg) => last_arg.push(arg_fragment), + None => { + let mut inner = mem::take(&mut sh.prog).into_os_string(); + inner.push(arg_fragment); + sh.prog = inner.into(); + } } } + inner(&mut self, arg_fragment.as_ref()); + self } /// Overrides the value of the environmental variable for this command. - pub fn env, V: AsRef>(mut self, key: K, val: V) -> Cmd<'a> { - self._env_set(key.as_ref(), val.as_ref()); + pub fn env(mut self, key: impl AsRef, val: impl AsRef) -> Cmd { + fn inner(sh: &mut Cmd, key: &OsStr, val: &OsStr) { + Arc::make_mut(&mut sh.sh.env).insert(key.into(), val.into()); + } + inner(&mut self, key.as_ref(), val.as_ref()); self } - fn _env_set(&mut self, key: &OsStr, val: &OsStr) { - self.data.env_changes.push(EnvChange::Set(key.to_owned(), val.to_owned())); - } - /// Overrides the values of specified environmental variables for this /// command. - pub fn envs(mut self, vars: I) -> Cmd<'a> + pub fn envs(mut self, vars: I) -> Cmd where I: IntoIterator, K: AsRef, V: AsRef, { - vars.into_iter().for_each(|(k, v)| self._env_set(k.as_ref(), v.as_ref())); + Arc::make_mut(&mut self.sh.env) + .extend(vars.into_iter().map(|(k, v)| (k.as_ref().into(), v.as_ref().into()))); self } /// Removes the environment variable from this command. - pub fn env_remove>(mut self, key: K) -> Cmd<'a> { - self._env_remove(key.as_ref()); + pub fn env_remove(mut self, key: impl AsRef) -> Cmd { + fn inner(sh: &mut Cmd, key: &OsStr) { + Arc::make_mut(&mut sh.sh.env).remove(key); + } + inner(&mut self, key.as_ref()); self } - fn _env_remove(&mut self, key: &OsStr) { - self.data.env_changes.push(EnvChange::Remove(key.to_owned())); - } /// Removes all of the environment variables from this command. - pub fn env_clear(mut self) -> Cmd<'a> { - self.data.env_changes.push(EnvChange::Clear); + pub fn env_clear(mut self) -> Cmd { + Arc::make_mut(&mut self.sh.env).clear(); self } - /// Don't return an error if command the command exits with non-zero status. - /// - /// By default, non-zero exit status is considered an error. - pub fn ignore_status(mut self) -> Cmd<'a> { + /// Pass the given slice to the standard input of the spawned process. + pub fn stdin(mut self, stdin: impl AsRef<[u8]>) -> Cmd { + fn inner(sh: &mut Cmd, stdin: &[u8]) { + sh.stdin_contents = Some(stdin.to_vec()); + } + inner(&mut self, stdin.as_ref()); + self + } + + /// Don't return an error if the command doesn't exit with status zero. + pub fn ignore_status(mut self) -> Cmd { self.set_ignore_status(true); self } - /// Controls whether non-zero exit status is considered an error. + + /// Whether to return an error if the command doesn't exit with status zero. pub fn set_ignore_status(&mut self, yes: bool) { - self.data.ignore_status = yes; + self.ignore_status = yes; } - /// Don't echo the command itself to stderr. - /// - /// By default, the command itself will be printed to stderr when executed via [`Cmd::run`]. - pub fn quiet(mut self) -> Cmd<'a> { - self.set_quiet(true); + /// Set timeout. + pub fn timeout(mut self, timeout: Duration) -> Cmd { + self.set_timeout(Some(timeout)); + self + } + + /// Set or clear timeout. + pub fn set_timeout(&mut self, timeout: Option) { + self.deadline = timeout.map(|it| Instant::now() + it) + } + + /// Set deadline. + pub fn deadline(mut self, deadline: Instant) -> Cmd { + self.set_deadline(Some(deadline)); self } - /// Controls whether the command itself is printed to stderr. - pub fn set_quiet(&mut self, yes: bool) { - self.data.quiet = yes; + + /// Set or clear deadline. + pub fn set_deadline(&mut self, deadline: Option) { + self.deadline = deadline; } /// Marks the command as secret. /// /// If a command is secret, it echoes `` instead of the program and /// its arguments, even in error messages. - pub fn secret(mut self) -> Cmd<'a> { + pub fn secret(mut self) -> Cmd { self.set_secret(true); self } /// Controls whether the command is secret. pub fn set_secret(&mut self, yes: bool) { - self.data.secret = yes; + self.secret = yes; } - /// Pass the given slice to the standard input of the spawned process. - pub fn stdin(mut self, stdin: impl AsRef<[u8]>) -> Cmd<'a> { - self._stdin(stdin.as_ref()); - self - } - fn _stdin(&mut self, stdin: &[u8]) { - self.data.stdin_contents = Some(stdin.to_vec()); - } - - /// Ignores the standard output stream of the process. + /// Run the command for side effects without printing anything. /// - /// This is equivalent to redirecting stdout to `/dev/null`. By default, the - /// stdout is inherited or captured. - pub fn ignore_stdout(mut self) -> Cmd<'a> { - self.set_ignore_stdout(true); - self + /// Use this in batch scripts that don't need to report intermediate progress (for example, in + /// tests). If the execution fails, the error will contain a suffix of stderr and stdout for + /// debugging. + /// + /// Internally, command's stdin is set to null, while stderr and stdout are piped. + pub fn run(&self) -> Result<()> { + let mut command = self.to_command(); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut result = exec::exec( + command, + self.stdin_contents.as_deref(), + Some(STREAM_SUFFIX_SIZE), + Some(STREAM_SUFFIX_SIZE), + self.deadline, + ); + self.check_exec_result(&mut result)?; + Ok(()) } - /// Controls whether the standard output is ignored. - pub fn set_ignore_stdout(&mut self, yes: bool) { - self.data.ignore_stdout = yes; + + fn check_exec_result(&self, result: &mut exec::ExecResult) -> Result<()> { + if let Some(status) = result.status { + if !status.success() && !self.ignore_status { + return Err(Error::new_cmd( + self, + CmdErrorKind::Status(status), + mem::take(&mut result.stdout), + mem::take(&mut result.stderr), + )); + } + } + if let Some(err) = result.error.take() { + if err.kind() == io::ErrorKind::TimedOut { + return Err(Error::new_cmd( + self, + CmdErrorKind::Timeout, + mem::take(&mut result.stdout), + mem::take(&mut result.stderr), + )); + } + return Err(Error::new_cmd( + self, + CmdErrorKind::Io(err), + mem::take(&mut result.stdout), + mem::take(&mut result.stderr), + )); + } + Ok(()) } - /// Ignores the standard output stream of the process. + /// Run the command for side effect, printing the command itself and its output. /// - /// This is equivalent redirecting stderr to `/dev/null`. By default, the - /// stderr is inherited or captured. - pub fn ignore_stderr(mut self) -> Cmd<'a> { - self.set_ignore_stderr(true); - self - } - /// Controls whether the standard error is ignored. - pub fn set_ignore_stderr(&mut self, yes: bool) { - self.data.ignore_stderr = yes; + /// Use this in interactive scenarios (when the human looks at the command being executed in + /// real time). + /// + /// Internally, command's stdin is set to null, while stderr and stdout are inherited. + pub fn run_echo(&self) -> Result<()> { + let mut command = self.to_command(); + command.stdin(Stdio::null()); + command.stdout(Stdio::inherit()); + command.stderr(Stdio::inherit()); + eprintln!("$ {}", self); + let mut child = command + .spawn() + .map_err(|err| Error::new_cmd(self, CmdErrorKind::Io(err), Vec::new(), Vec::new()))?; + let status = exec::wait_deadline(&mut child, self.deadline) + .map_err(|err| Error::new_cmd(self, CmdErrorKind::Io(err), Vec::new(), Vec::new()))?; + if !status.success() { + return Err(Error::new_cmd(self, CmdErrorKind::Status(status), Vec::new(), Vec::new())); + } + + Ok(()) } - // endregion:builder - // region:running - /// Runs the command. + /// Like `exec_echo`, but also inherit stdin. /// - /// By default the command itself is echoed to stderr, its standard streams - /// are inherited, and non-zero return code is considered an error. These - /// behaviors can be overridden by using various builder methods of the [`Cmd`]. - pub fn run(&self) -> Result<()> { - if !self.data.quiet { - eprintln!("$ {}", self); + /// Use this when the user needs to type some input in. + pub fn run_interactive(&self) -> Result<()> { + let mut command = self.to_command(); + command.stdin(Stdio::inherit()); + command.stdout(Stdio::inherit()); + command.stderr(Stdio::inherit()); + eprintln!("$ {}", self); + let mut child = command + .spawn() + .map_err(|err| Error::new_cmd(self, CmdErrorKind::Io(err), Vec::new(), Vec::new()))?; + let status = exec::wait_deadline(&mut child, self.deadline) + .map_err(|err| Error::new_cmd(self, CmdErrorKind::Io(err), Vec::new(), Vec::new()))?; + if !status.success() { + return Err(Error::new_cmd(self, CmdErrorKind::Status(status), Vec::new(), Vec::new())); } - self.output_impl(false, false).map(|_| ()) + Ok(()) } - /// Run the command and return its stdout as a string. Any trailing newline or carriage return will be trimmed. + /// Run the command and read its standard output to string. + /// + /// If the output is exactly one line, the final newline is stripped. pub fn read(&self) -> Result { - self.read_stream(false) - } - - /// Run the command and return its stderr as a string. Any trailing newline or carriage return will be trimmed. + let mut command = self.to_command(); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut result = exec::exec( + command, + self.stdin_contents.as_deref(), + None, + Some(STREAM_SUFFIX_SIZE), + self.deadline, + ); + self.check_exec_result(&mut result)?; + self.chomp(result.stdout) + } + + /// Run the command and read its standard error to string. + /// + /// If the output is exactly one line, the final newline is stripped. pub fn read_stderr(&self) -> Result { - self.read_stream(true) - } - - /// Run the command and return its output. - pub fn output(&self) -> Result { - self.output_impl(true, true) - } - // endregion:running - - fn read_stream(&self, read_stderr: bool) -> Result { - let read_stdout = !read_stderr; - let output = self.output_impl(read_stdout, read_stderr)?; - self.check_status(output.status)?; - - let stream = if read_stderr { output.stderr } else { output.stdout }; - let mut stream = String::from_utf8(stream).map_err(|err| Error::new_cmd_utf8(self, err))?; - - if stream.ends_with('\n') { - stream.pop(); - } - if stream.ends_with('\r') { - stream.pop(); + let mut command = self.to_command(); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut result = exec::exec( + command, + self.stdin_contents.as_deref(), + Some(STREAM_SUFFIX_SIZE), + None, + self.deadline, + ); + self.check_exec_result(&mut result)?; + self.chomp(result.stderr) + } + + fn chomp(&self, stream: Vec) -> Result { + let mut text = String::from_utf8(stream) + .map_err(|err| Error::new_cmd(self, CmdErrorKind::Utf8(err), Vec::new(), Vec::new()))?; + if text.ends_with('\n') && !text[0..text.len() - 1].contains('\n') { + text.pop(); + if text.ends_with('\r') { + text.pop(); + } } - - Ok(stream) + Ok(text) } - fn output_impl(&self, read_stdout: bool, read_stderr: bool) -> Result { - let mut child = { - let mut command = self.to_command(); - - if !self.data.ignore_stdout { - command.stdout(if read_stdout { Stdio::piped() } else { Stdio::inherit() }); - } - if !self.data.ignore_stderr { - command.stderr(if read_stderr { Stdio::piped() } else { Stdio::inherit() }); - } + /// Run the command and return its full output. + pub fn output(&self) -> Result { + let mut command = self.to_command(); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); - command.stdin(match &self.data.stdin_contents { - Some(_) => Stdio::piped(), - None => Stdio::null(), - }); - - command.spawn().map_err(|err| { - // Try to determine whether the command failed because the current - // directory does not exist. Return an appropriate error in such a - // case. - if matches!(err.kind(), io::ErrorKind::NotFound) { - let cwd = self.shell.cwd.borrow(); - if let Err(err) = cwd.metadata() { - return Error::new_current_dir(err, Some(cwd.clone())); - } - } - Error::new_cmd_io(self, err) - })? - }; - - let mut io_thread = None; - if let Some(stdin_contents) = self.data.stdin_contents.clone() { - let mut stdin = child.stdin.take().unwrap(); - io_thread = Some(std::thread::spawn(move || { - stdin.write_all(&stdin_contents)?; - stdin.flush() - })); - } - let out_res = child.wait_with_output(); - let err_res = io_thread.map(|it| it.join().unwrap()); - let output = out_res.map_err(|err| Error::new_cmd_io(self, err))?; - if let Some(err_res) = err_res { - err_res.map_err(|err| Error::new_cmd_stdin(self, err))?; - } - self.check_status(output.status)?; - Ok(output) + let mut result = + exec::exec(command, self.stdin_contents.as_deref(), None, None, self.deadline); + self.check_exec_result(&mut result)?; + Ok(Output { + status: result.status.take().unwrap(), + stdout: result.stdout, + stderr: result.stderr, + }) } /// Constructs a [`std::process::Command`] for the same command as `self`. @@ -1058,37 +1063,11 @@ impl<'a> Cmd<'a> { /// Other builder methods have no effect on the command returned since they /// control how the command is run, but this method does not yet execute the command. pub fn to_command(&self) -> Command { - let mut res = Command::new(&self.data.prog); - res.current_dir(self.shell.current_dir()); - res.args(&self.data.args); - - for (key, val) in &*self.shell.env.borrow() { - res.env(key, val); - } - for change in &self.data.env_changes { - match change { - EnvChange::Clear => res.env_clear(), - EnvChange::Remove(key) => res.env_remove(key), - EnvChange::Set(key, val) => res.env(key, val), - }; - } - - if self.data.ignore_stdout { - res.stdout(Stdio::null()); - } - - if self.data.ignore_stderr { - res.stderr(Stdio::null()); - } - - res - } - - fn check_status(&self, status: ExitStatus) -> Result<()> { - if status.success() || self.data.ignore_status { - return Ok(()); - } - Err(Error::new_cmd_status(self, status)) + let mut result = Command::new(&self.prog); + result.current_dir(&self.sh.cwd); + result.args(&self.args); + result.envs(&*self.sh.env); + result } } diff --git a/tests/compile_time.rs b/tests/compile_time.rs index ca35eef..7926b36 100644 --- a/tests/compile_time.rs +++ b/tests/compile_time.rs @@ -4,26 +4,26 @@ use xshell::{cmd, Shell}; #[test] fn fixed_cost_compile_times() { - let sh = Shell::new().unwrap(); + let mut sh = Shell::new().unwrap(); - let _p = sh.push_dir("tests/data"); - let baseline = compile_bench(&sh, "baseline"); + let _p = sh.set_current_dir("tests/data"); + let baseline = compile_bench(&mut sh, "baseline"); let _ducted = compile_bench(&sh, "ducted"); - let xshelled = compile_bench(&sh, "xshelled"); + let xshelled = compile_bench(&mut sh, "xshelled"); let ratio = (xshelled.as_millis() as f64) / (baseline.as_millis() as f64); assert!(1.0 < ratio && ratio < 10.0); fn compile_bench(sh: &Shell, name: &str) -> Duration { - let _p = sh.push_dir(name); + let sh = sh.with_current_dir(name); let cargo_build = cmd!(sh, "cargo build -q"); - cargo_build.read().unwrap(); + cargo_build.run().unwrap(); let n = 5; let mut times = Vec::new(); for _ in 0..n { sh.remove_path("./target").unwrap(); let start = Instant::now(); - cargo_build.read().unwrap(); + cargo_build.run().unwrap(); let elapsed = start.elapsed(); times.push(elapsed); } diff --git a/tests/it/compile_failures.rs b/tests/it/compile_failures.rs index 611af5a..fd9c8f7 100644 --- a/tests/it/compile_failures.rs +++ b/tests/it/compile_failures.rs @@ -2,10 +2,10 @@ use xshell::{cmd, Shell}; #[track_caller] fn check(code: &str, err_msg: &str) { - let sh = Shell::new().unwrap(); - let xshell_dir = sh.current_dir(); + let mut sh = Shell::new().unwrap(); + let xshell_dir = sh.current_dir().to_owned(); let temp_dir = sh.create_temp_dir().unwrap(); - sh.change_dir(temp_dir.path()); + sh.set_current_dir(temp_dir.path()); let manifest = format!( r#" diff --git a/tests/it/env.rs b/tests/it/env.rs index 1933dfe..9d5d2bb 100644 --- a/tests/it/env.rs +++ b/tests/it/env.rs @@ -6,7 +6,7 @@ use crate::setup; #[test] fn test_env() { - let sh = setup(); + let mut sh = setup(); let v1 = "xshell_test_123"; let v2 = "xshell_test_456"; @@ -34,8 +34,8 @@ fn test_env() { ); } - let _g1 = sh.push_env(v1, "foobar"); - let _g2 = sh.push_env(v2, "quark"); + let _g1 = sh.set_env_var(v1, "foobar"); + let _g2 = sh.set_env_var(v2, "quark"); assert_env(cmd!(sh, "xecho -$ {v1} {v2}"), &[(v1, Some("foobar")), (v2, Some("quark"))]); assert_env(cmd!(cloned_sh, "xecho -$ {v1} {v2}"), &[(v1, None), (v2, None)]); @@ -58,7 +58,7 @@ fn test_env() { #[test] fn test_env_clear() { - let sh = setup(); + let mut sh = setup(); let v1 = "xshell_test_123"; let v2 = "xshell_test_456"; @@ -79,8 +79,8 @@ fn test_env_clear() { &[(v1, Some("789")), (v2, None)], ); - let _g1 = sh.push_env(v1, "foobar"); - let _g2 = sh.push_env(v2, "quark"); + let _g1 = sh.set_env_var(v1, "foobar"); + let _g2 = sh.set_env_var(v2, "quark"); assert_env(cmd!(sh, "{xecho} -$ {v1} {v2}").env_clear(), &[(v1, None), (v2, None)]); assert_env( diff --git a/tests/it/main.rs b/tests/it/main.rs index fa8638d..312789c 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -9,18 +9,17 @@ use xshell::{cmd, Shell}; fn setup() -> Shell { static ONCE: std::sync::Once = std::sync::Once::new(); - let sh = Shell::new().unwrap(); + let mut sh = Shell::new().unwrap(); let xecho_src = sh.current_dir().join("./tests/data/xecho.rs"); let target_dir = sh.current_dir().join("./target/"); ONCE.call_once(|| { cmd!(sh, "rustc {xecho_src} --out-dir {target_dir}") - .quiet() .run() .unwrap_or_else(|err| panic!("failed to install binaries from mock_bin: {}", err)) }); - sh.set_var("PATH", target_dir); + sh.set_env_var("PATH", target_dir); sh } @@ -89,7 +88,7 @@ fn program_concatenation() { let sh = setup(); let ho = "ho"; - let output = cmd!(sh, "xec{ho} hello").read().unwrap(); + let output = dbg!(cmd!(sh, "xec{ho} hello")).read().unwrap(); assert_eq!(output, "hello"); } @@ -247,11 +246,11 @@ fn test_push_dir() { let d1 = sh.current_dir(); { - let _p = sh.push_dir("xshell-macros"); + let sh = sh.with_current_dir("xshell-macros"); let d2 = sh.current_dir(); assert_eq!(d2, d1.join("xshell-macros")); { - let _p = sh.push_dir("src"); + let sh = sh.with_current_dir("src"); let d3 = sh.current_dir(); assert_eq!(d3, d1.join("xshell-macros/src")); } @@ -268,10 +267,10 @@ fn test_push_and_change_dir() { let d1 = sh.current_dir(); { - let _p = sh.push_dir("xshell-macros"); + let mut sh = sh.with_current_dir("xshell-macros"); let d2 = sh.current_dir(); assert_eq!(d2, d1.join("xshell-macros")); - sh.change_dir("src"); + sh.set_current_dir("src"); let d3 = sh.current_dir(); assert_eq!(d3, d1.join("xshell-macros/src")); } @@ -285,98 +284,54 @@ fn push_dir_parent_dir() { let current = sh.current_dir(); let dirname = current.file_name().unwrap(); - let _d = sh.push_dir(".."); - let _d = sh.push_dir(dirname); + let sh = sh.with_current_dir(".."); + let sh = sh.with_current_dir(dirname); assert_eq!(sh.current_dir().canonicalize().unwrap(), current.canonicalize().unwrap()); } const VAR: &str = "SPICA"; #[test] -fn test_push_env() { +fn test_subshells_env() { let sh = setup(); - let e1 = sh.var_os(VAR); + let e1 = sh.env_var_os(VAR); { - let _e = sh.push_env(VAR, "1"); - let e2 = sh.var_os(VAR); - assert_eq!(e2, Some("1".into())); + let mut sh = sh.clone(); + sh.set_env_var(VAR, "1"); + let e2 = sh.env_var_os(VAR); + assert_eq!(e2.as_deref(), Some("1".as_ref())); { - let _e = sh.push_env(VAR, "2"); - let e3 = sh.var_os(VAR); - assert_eq!(e3, Some("2".into())); + let mut sh = sh.clone(); + let _e = sh.set_env_var(VAR, "2"); + let e3 = sh.env_var_os(VAR); + assert_eq!(e3.as_deref(), Some("2".as_ref())); } - let e4 = sh.var_os(VAR); + let e4 = sh.env_var_os(VAR); assert_eq!(e4, e2); } - let e5 = sh.var_os(VAR); + let e5 = sh.env_var_os(VAR); assert_eq!(e5, e1); } #[test] -fn test_push_env_clone() { +fn test_push_env_and_set_env_var() { let sh = setup(); - assert!(sh.var_os(VAR).is_none()); - let guard = sh.push_env(VAR, "1"); - let cloned = sh.clone(); - drop(guard); - assert_eq!(sh.var_os(VAR), None); - assert_eq!(cloned.var_os(VAR), Some("1".into())); -} - -#[test] -fn test_push_env_and_set_var() { - let sh = setup(); - - let e1 = sh.var_os(VAR); + let e1 = sh.env_var_os(VAR); { - let _e = sh.push_env(VAR, "1"); - let e2 = sh.var_os(VAR); - assert_eq!(e2, Some("1".into())); - let _e = sh.set_var(VAR, "2"); - let e3 = sh.var_os(VAR); - assert_eq!(e3, Some("2".into())); + let mut sh = sh.clone(); + sh.set_env_var(VAR, "1"); + let e2 = sh.env_var_os(VAR); + assert_eq!(e2.as_deref(), Some("1".as_ref())); + sh.set_env_var(VAR, "2"); + let e3 = sh.env_var_os(VAR); + assert_eq!(e3.as_deref(), Some("2".as_ref())); } - let e5 = sh.var_os(VAR); + let e5 = sh.env_var_os(VAR); assert_eq!(e5, e1); } -#[test] -fn output_with_ignore() { - let sh = setup(); - - let output = cmd!(sh, "xecho -e 'hello world!'").ignore_stdout().output().unwrap(); - assert_eq!(output.stderr, b"hello world!\n"); - assert_eq!(output.stdout, b""); - - let output = cmd!(sh, "xecho -e 'hello world!'").ignore_stderr().output().unwrap(); - assert_eq!(output.stdout, b"hello world!\n"); - assert_eq!(output.stderr, b""); - - let output = - cmd!(sh, "xecho -e 'hello world!'").ignore_stdout().ignore_stderr().output().unwrap(); - assert_eq!(output.stdout, b""); - assert_eq!(output.stderr, b""); -} - -#[test] -fn test_read_with_ignore() { - let sh = setup(); - - let stdout = cmd!(sh, "xecho -e 'hello world'").ignore_stdout().read().unwrap(); - assert!(stdout.is_empty()); - - let stderr = cmd!(sh, "xecho -e 'hello world'").ignore_stderr().read_stderr().unwrap(); - assert!(stderr.is_empty()); - - let stdout = cmd!(sh, "xecho -e 'hello world!'").ignore_stderr().read().unwrap(); - assert_eq!(stdout, "hello world!"); - - let stderr = cmd!(sh, "xecho -e 'hello world!'").ignore_stdout().read_stderr().unwrap(); - assert_eq!(stderr, "hello world!"); -} - #[test] fn test_copy_file() { let sh = setup(); @@ -391,10 +346,10 @@ fn test_copy_file() { sh.write_file(&foo, "hello world").unwrap(); sh.create_dir(&dir).unwrap(); - sh.copy_file(&foo, &bar).unwrap(); + sh.copy_file_to_path(&foo, &bar).unwrap(); assert_eq!(sh.read_file(&bar).unwrap(), "hello world"); - sh.copy_file(&foo, &dir).unwrap(); + sh.copy_file_to_dir(&foo, &dir).unwrap(); assert_eq!(sh.read_file(&dir.join("foo.txt")).unwrap(), "hello world"); assert!(path.exists()); } @@ -403,16 +358,16 @@ fn test_copy_file() { #[test] fn test_exists() { - let sh = setup(); + let mut sh = setup(); let tmp = sh.create_temp_dir().unwrap(); - let _d = sh.change_dir(tmp.path()); + let _d = sh.set_current_dir(tmp.path()); assert!(!sh.path_exists("foo.txt")); sh.write_file("foo.txt", "foo").unwrap(); assert!(sh.path_exists("foo.txt")); assert!(!sh.path_exists("bar")); sh.create_dir("bar").unwrap(); assert!(sh.path_exists("bar")); - let _d = sh.change_dir("bar"); + let _d = sh.set_current_dir("bar"); assert!(!sh.path_exists("quz.rs")); sh.write_file("quz.rs", "fn main () {}").unwrap(); assert!(sh.path_exists("quz.rs")); @@ -432,10 +387,10 @@ fn write_makes_directory() { #[test] fn test_remove_path() { - let sh = setup(); + let mut sh = setup(); let tempdir = sh.create_temp_dir().unwrap(); - sh.change_dir(tempdir.path()); + sh.set_current_dir(tempdir.path()); sh.write_file(Path::new("a/b/c.rs"), "fn main() {}").unwrap(); assert!(tempdir.path().join("a/b/c.rs").exists()); sh.remove_path("./a").unwrap(); @@ -453,7 +408,7 @@ fn recovers_from_panics() { let orig = sh.current_dir(); std::panic::catch_unwind(|| { - let _p = sh.push_dir(&tempdir); + let sh = sh.with_current_dir(&tempdir); assert_eq!(sh.current_dir(), tempdir); std::panic::resume_unwind(Box::new(())); }) @@ -461,7 +416,7 @@ fn recovers_from_panics() { assert_eq!(sh.current_dir(), orig); { - let _p = sh.push_dir(&tempdir); + let sh = sh.with_current_dir(&tempdir); assert_eq!(sh.current_dir(), tempdir); } } @@ -477,8 +432,8 @@ fn string_escapes() { #[test] fn nonexistent_current_directory() { - let sh = setup(); - sh.change_dir("nonexistent"); + let mut sh = setup(); + sh.set_current_dir("nonexistent"); let err = cmd!(sh, "ls").run().unwrap_err(); let message = err.to_string(); if cfg!(unix) { diff --git a/tests/it/tidy.rs b/tests/it/tidy.rs index 3d6f90d..6bcacf5 100644 --- a/tests/it/tidy.rs +++ b/tests/it/tidy.rs @@ -27,8 +27,8 @@ fn formatting() { #[test] fn current_version_in_changelog() { - let sh = Shell::new().unwrap(); - let _p = sh.push_dir(env!("CARGO_MANIFEST_DIR")); + let mut sh = Shell::new().unwrap(); + sh.set_current_dir(env!("CARGO_MANIFEST_DIR")); let changelog = sh.read_file("CHANGELOG.md").unwrap(); let current_version_header = format!("## {}", env!("CARGO_PKG_VERSION")); assert_eq!(changelog.lines().filter(|&line| line == current_version_header).count(), 1); diff --git a/xshell-macros/Cargo.toml b/xshell-macros/Cargo.toml index 08d2b5e..6644f54 100644 --- a/xshell-macros/Cargo.toml +++ b/xshell-macros/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "xshell-macros" description = "Private implementation detail of xshell crate" -version = "0.2.6" +version = "0.2.7" license = "MIT OR Apache-2.0" repository = "https://github.com/matklad/xshell" authors = ["Aleksey Kladov "]