Crate xscript

source ·
Expand description

XScript

A library for writing robust shell-script-like programs and running commands anywhere with ease.

The initial motivation for this crate was xtask development. Taken from the xtask of this crate, here is an example for building the documentation you are currently reading with the nightly toolchain and proper --cfg flags:

use xscript::{read_str, run, vars, Run, LocalEnv};

let mut env = LocalEnv::current_dir()?.with_vars(vars! {
    RUSTDOCFLAGS = "--cfg docsrs --cfg xscript_unstable",
    RUSTFLAGS = "--cfg xscript_unstable",
});

let project_root = read_str!(env, ["git", "rev-parse", "--show-toplevel"])?;
env.change_dir(project_root)?;

let cargo_args = ["+nightly"];
let doc_args = ["--lib", "--all-features"];
run!(env, ["cargo", ...cargo_args, "doc", ...doc_args])?;

🙏 Acknowledgements: The design of this crate has been heavily inspired by xshell. See Related Crates for details.

🤔 Rationale: Executing commands via std::process::Command can be quite cumbersome. You have to be careful to check the exit status of the child process as otherwise errors may go unnoticed, you have to take care of the input and output manually, and you have to prepare the environment and set the working directory for each command individually.1 While this fine-grained control is the correct choice for the standard library – and, in fact, an explicit goal – this crate aims to provide more convenient APIs for common shell-script-like use cases where multiple commands are executed in succession.

🎯 Goals and Non-Goals: This crate aims to provide convenient and hard-to-misuse APIs for writing robust shell-script-like programs where multiple commands are executed in succession and in the same environment. For this purpose, an environment may also be a Docker container or a remote machine. This crate does not aim to be a general-purpose solution for all command launching needs, though, and instead favors ease of use for common use cases.

User Guide

Let’s first establish some base terminology and corresponding types:

  • A command consists of a program and arguments. Commands are represented by the Cmd type. In addition to the program and its arguments, the Cmd type may also hold further information, e.g., the stdin input to provide.
  • An environment provides a context in which commands can be executed. Usually, it consists of a working directory and values for environment variables. LocalEnv is an environment for executing commands locally.

This separation of commands and environments is central to shell-script-like programs. It enables the convenient execution of multiple commands in the same environment without rebuilding it all the time.

Commands

For convenient command construction, the cmd! macro is provided. The first argument of the cmd! macro becomes the program of the command. The remaining arguments become the arguments of the command. Here is a simple example:

cmd!("git", "rev-parse", "--show-toplevel");

When using string literals, the program and arguments both support string interpolation with format!:

let prefix = "/usr/bin";
let user = "silitics";
let repo = "xscript-rs";
cmd!("{prefix}/git", "clone", "https://github.com/{user}/{repo}.git");

Instead of string literals, any expression implementing AsRef<str> can be used for the program and its arguments. Further, to extend the arguments of a command with an iterable, any iterable expression can be prefixed with ...:

const CARGO: &str = "cargo";
let cargo_command = "doc";
let cargo_args = ["+nightly"];
let extra_args = ["--lib", "--all-features"];
cmd!(CARGO, ...cargo_args, cargo_command, ...extra_args);

After constructing a command with cmd!, builder methods can be called to further configure it (see Cmd for details). For instance, environment variables can be set per command and the stdin input can be specified:

cmd!("cargo", "+nightly", "doc").with_var("RUSTDOCFLAGS", "--cfg docsrs");
cmd!("gzip", "--best").with_stdin("Compress this string!");

Note that cmd! merely constructs the command but does not yet execute it.

Environments

Commands are executed in environments. Note that we expressly intend for third-party crates to implement environments, e.g., for running commands on a remote machine via SSH. Behind an unstable feature flag (see Optional Features), this crate also provides an experimental environment for running commands in a Docker container.

Environments should implement the Run and/or the RunAsync trait.

Sane I/O Defaults: Unless otherwise specified as part of a command, environments should set stdin to In::Null by default. Inheriting stdin is dangerous for two reasons, (a) a command may wait for input and, thus, block the main program, and (b) a command may interfere with the main program. Likewise, by default, all outputs should be captured, i.e., stdout and stderr should be set to Out::Capture by default. This means, that they are both available as part of RunError or RunOutput independently of whether the program succeeds or fails. The stderr output should also be replayed by default, i.e., written to the parent process’ stderr stream (after the command terminated).

Running Commands

After constructing a command, the methods of the Run (RunAsync) trait can be used to run it. For convenience, the macros run!, read_str!, and read_bytes! are provided combining the construction and execution. For instance

read_str!(env, ["git", "rev-parse", "--show-toplevel"])?;

runs git rev-parse --show-toplevel (which outputs a Git repository’s root directory on stdout) and reads its stdout output as a string. Here, everything inside the brackets [...] is forwarded to the cmd! macro (see Commands). The syntax of run! and read_bytes! is analogous. In addition, calls to Cmd builder methods can simply be appended:

read_bytes!(env, ["gzip", "--best"].with_stdin("Compress this string!"))?;

If the environment is omitted, then ParentEnv is used. This allows running commands in the environment of the parent process, e.g., in cases where the environment does not matter or inheriting the environment is intended.

Optional Features

This crate offers the following optional features:

  • serde: Support for serialization and deserialization of various data structures via Serde.

🚧 In addition, there are also the following unstable work-in-progress features:

  • async: Support for asynchronous environments (in particular, the RunAsync trait).
  • docker: Support for running commands asynchronously in a Docker container.
  • tokio: Support for running commands asynchronously using Tokio.

⚠️ Warning: Unstable means that they may not even be in a working state. They require --cfg xscript_unstable.

This crate is by far not the first aiming to simplify command launching. The crates xshell, devx-cmd, and duct start with the same motivation. In terms of both, its design and its goals, xshell comes closest to xscript. In contrast to devx-cmd and duct, xshell and xscript aim to separate the execution environment from the commands being launched. This separation is central to shell-script-like use cases because it enables launching multiple commands in the same environment.

In case of xshell, the Shell type provides a common environment for launching multiple commands. Just like a shell, it has a current working directory in which commands are launched and defines environment variables. Note, however, that xshell still couples every command to a Shell. Now, xscript follows the same idea but its command type Cmd is completely decoupled from the execution environment. Instead of calling a method on the command to launch it, the idea is to provide the command as an argument to a method of an environment. An environment then defines the exact way in which commands are launched. For instance, they may launch the command in a Docker container or via SSH on a remote machine, and they may offer asynchronous launch methods. Crucially, environments can also be defined by third-party crates. Another technical difference between xshell and xscript is that xshell uses a proc macro and parses a command provided as a string while xscript does not do any string parsing but requires the arguments to be given individually. In contrast to xscript, xshell’s Shell environment also offers methods to work with the filesystem. We may also add such functionality to xscript later.

Note that xscript does not support building pipelines where the output of one command is continuously fed as an input to another command running at the same time. In case you need pipelines, consider using duct instead.

In contrast to all other crates mentioned above, xscript is generic over the command’s string type. It supports both OsString- and String-based commands for local and portable commands, respectively. Most macros rely on Rust’s type inference for the correct string type. For the cmd! macro, there also exist specialized variants.


  1. Or, modify the global environment of the parent process. 

Modules

  • dockerdocker
    Run commands asynchronously in a Docker container.
  • tokiotokio
    Run commands asynchronously using Tokio.

Macros

Structs

  • A command.
  • A local execution environment.
  • Execution environment of the parent process.
  • Error running a command.
  • Output produced when running a command.
  • A set of environment variables.

Enums

  • An input provided to a command.
  • Indicates what to do with the output of a command.
  • Error while running a command.

Traits

  • A string type that can be used to construct commands.
  • Trait for running commands in an execution environment.
  • Trait for running commands asynchronously in an execution environment.
  • Lossy string conversion.

Type Aliases