Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for asynchronous execution #91

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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.6" # also update xshell-macros/Cargo.toml and CHANGELOG.md
license = "MIT OR Apache-2.0"
repository = "https://github.com/matklad/xshell"
authors = ["Aleksey Kladov <[email protected]>"]
Expand All @@ -11,10 +11,24 @@ rust-version = "1.63"

exclude = [".github/", "bors.toml", "rustfmt.toml", "cbench", "mock_bin/"]

[features]
async = ["tokio"]

[workspace]

[dependencies]
xshell-macros = { version = "=0.2.6", path = "./xshell-macros" }
tokio = { version = "1.38.0", features = [
"process",
"io-std",
"io-util",
"rt",
], optional = true }

[dev-dependencies]
anyhow = "1.0.56"
tokio = { version = "1.38.0", features = ["full"] }

[[example]]
name = "async_timeout"
required-features = ["async"]
48 changes: 48 additions & 0 deletions examples/async_timeout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use anyhow::Result;

#[tokio::main]
async fn main() -> Result<()> {
#[cfg(feature = "async")]
timeout_example().await;

#[cfg(feature = "async")]
no_timeout_example().await;

Ok(())
}

#[cfg(feature = "async")]
async fn timeout_example() {
use std::time::Duration;

use anyhow::{anyhow, Context};
use tokio::time::timeout;
use xshell::{cmd, Shell};

let sh = Shell::new().unwrap();
let cmd = cmd!(sh, "sleep 5");
let res = match timeout(Duration::from_secs(3), cmd.read_async()).await {
Ok(result) => result.context("Run failed"),
Err(e) => Err(anyhow!("Timeout: {e}")),
};

println!("Should timeout: {res:?}");
}

#[cfg(feature = "async")]
async fn no_timeout_example() {
use std::time::Duration;

use anyhow::{anyhow, Context};
use tokio::time::timeout;
use xshell::{cmd, Shell};

let sh = Shell::new().unwrap();
let cmd = cmd!(sh, "echo Hello");
let res = match timeout(Duration::from_secs(3), cmd.read_async()).await {
Ok(result) => result.context("Run failed"),
Err(e) => Err(anyhow!("Timeout: {e}")),
};

println!("Should echo: {res:?}");
}
98 changes: 98 additions & 0 deletions src/async_ext.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::process::Output;
use std::process::Stdio;

use tokio::io::AsyncWriteExt;

use crate::Cmd;
use crate::Error;
use crate::Result;

impl<'a> Cmd<'a> {
// region:running
/// Runs the command **asynchronously**.
///
/// 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_async(&self) -> Pin<Box<dyn Future<Output = Result<()>> + '_>> {
Box::pin(async move { self.output_impl_async(false, false).await.map(|_| ()) })
}

/// Run the command **asynchronously** and return its stdout as a string. Any trailing newline or carriage return will be trimmed.
pub fn read_async(&self) -> Pin<Box<dyn Future<Output = Result<String>> + '_>> {
Box::pin(async move { self.read_stream_async(false).await })
}

/// Run the command **asynchronously** and return its stderr as a string. Any trailing newline or carriage return will be trimmed.
pub fn read_stderr_async(&self) -> Pin<Box<dyn Future<Output = Result<String>> + '_>> {
Box::pin(async move { self.read_stream_async(true).await })
}

/// Run the command **asynchronously** and return its output.
pub fn output_async(&self) -> Pin<Box<dyn Future<Output = Result<Output>> + '_>> {
Box::pin(async move { self.output_impl_async(true, true).await })
}

async fn read_stream_async(&self, read_stderr: bool) -> Result<String> {
let read_stdout = !read_stderr;
let output = self.output_impl_async(read_stdout, read_stderr).await?;
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();
}

Ok(stream)
}

async fn output_impl_async(
&self,
read_stdout: bool,
read_stderr: bool,
) -> Result<Output, Error> {
let mut command = tokio::process::Command::from(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() });
}

command.stdin(match &self.data.stdin_contents {
Some(_) => Stdio::piped(),
None => Stdio::null(),
});

let mut child = command.spawn().map_err(|err| {
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)
})?;

if let Some(stdin_contents) = self.data.stdin_contents.clone() {
let mut stdin = child.stdin.take().unwrap();
tokio::spawn(async move {
stdin.write_all(&stdin_contents).await?;
stdin.flush().await
});
}

let output = child.wait_with_output().await.map_err(|err| Error::new_cmd_io(self, err))?;
self.check_status(output.status)?;
Ok(output)
}
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@

mod error;

#[cfg(feature = "async")]
mod async_ext;

use std::{
cell::RefCell,
collections::HashMap,
Expand Down
73 changes: 73 additions & 0 deletions tests/it/async_ext.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use xshell::cmd;

use super::setup;

#[tokio::test]
async fn test_run_async() {
let sh = setup();
sh.change_dir("nonexistent");
let err = cmd!(sh, "ls").run_async().await.unwrap_err();
let message = err.to_string();
if cfg!(unix) {
assert!(message.contains("nonexistent"), "{message}");
assert!(message.starts_with("failed to get current directory"));
assert!(message.ends_with("No such file or directory (os error 2)"));
} else {
assert_eq!(
message,
"io error when running command `ls`: The directory name is invalid. (os error 267)"
);
}
}

#[tokio::test]
async fn test_read_async() {
let sh = setup();

let hello = "hello";
let output = cmd!(sh, "xecho {hello}").read_async().await.unwrap();
assert_eq!(output, "hello");
}

#[tokio::test]
async fn test_read_stderr_async() {
let sh = setup();

let output = cmd!(sh, "xecho -f -e snafu").ignore_status().read_stderr_async().await.unwrap();
assert!(output.contains("snafu"));
}

#[tokio::test]
async 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"");
}

#[tokio::test]
async 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!");
}
3 changes: 3 additions & 0 deletions tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ mod tidy;
mod env;
mod compile_failures;

#[cfg(feature = "async")]
mod async_ext;

use std::{ffi::OsStr, path::Path};

use xshell::{cmd, Shell};
Expand Down
Loading