Skip to main content

assert_cmd/
cargo.rs

1//! Simplify running `bin`s in a Cargo project.
2//!
3//! [`CommandCargoExt`] is an extension trait for [`Command`] to easily launch a crate's
4//! binaries.
5//!
6//! # Examples
7//!
8//! Simple case:
9//!
10//! ```rust,no_run
11//! use assert_cmd::prelude::*;
12//! use assert_cmd::pkg_name;
13//!
14//! use std::process::Command;
15//!
16//! let mut cmd = Command::cargo_bin(pkg_name!())
17//!     .unwrap();
18//! let output = cmd.unwrap();
19//! ```
20//!
21//! # Limitations
22//!
23//! - Only works within the context of integration tests.  See [`escargot`] for a more
24//!   flexible API.
25//! - Only reuses your existing feature flags, targets, or build mode.
26//! - Only works with cargo binaries (`cargo test` ensures they are built).
27//!
28//! If you run into these limitations, we recommend trying out [`escargot`]:
29//!
30//! ```rust,no_run
31//! use assert_cmd::prelude::*;
32//!
33//! use std::process::Command;
34//!
35//! let bin_under_test = escargot::CargoBuild::new()
36//!     .bin("bin_fixture")
37//!     .current_release()
38//!     .current_target()
39//!     .run()
40//!     .unwrap();
41//! let mut cmd = bin_under_test.command();
42//! let output = cmd.unwrap();
43//! println!("{:?}", output);
44//! ```
45//!
46//! Notes:
47//! - There is a [noticeable per-call overhead][cargo-overhead] for `CargoBuild`.  We recommend
48//!   caching the binary location (`.path()` instead of `.command()`) with [`lazy_static`].
49//! - `.current_target()` improves platform coverage at the cost of [slower test runs if you don't
50//!   explicitly pass `--target <TRIPLET>` on the command line][first-call].
51//!
52//! [`lazy_static`]: https://crates.io/crates/lazy_static
53//! [`Command`]: std::process::Command
54//! [`escargot`]: https://crates.io/crates/escargot
55//! [cargo-overhead]: https://github.com/assert-rs/assert_cmd/issues/6
56//! [first-call]: https://github.com/assert-rs/assert_cmd/issues/57
57
58use std::env;
59use std::error::Error;
60use std::fmt;
61use std::path;
62use std::process;
63
64#[doc(inline)]
65pub use crate::cargo_bin;
66#[doc(inline)]
67pub use crate::cargo_bin_cmd;
68
69/// Create a [`Command`] for a `bin` in the Cargo project.
70///
71/// `CommandCargoExt` is an extension trait for [`Command`][std::process::Command] to easily launch a crate's
72/// binaries.
73///
74/// See the [`cargo` module documentation][super::cargo] for caveats and workarounds.
75///
76/// # Examples
77///
78/// ```rust,no_run
79/// use assert_cmd::prelude::*;
80/// use assert_cmd::pkg_name;
81///
82/// use std::process::Command;
83///
84/// let mut cmd = Command::cargo_bin(pkg_name!())
85///     .unwrap();
86/// let output = cmd.unwrap();
87/// println!("{:?}", output);
88/// ```
89///
90/// [`Command`]: std::process::Command
91pub trait CommandCargoExt
92where
93    Self: Sized,
94{
95    /// Create a [`Command`] to run a specific binary of the current crate.
96    ///
97    /// See the [`cargo` module documentation][crate::cargo] for caveats and workarounds.
98    ///
99    /// The [`Command`] created with this method may run the binary through a runner, as configured
100    /// in the `CARGO_TARGET_<TRIPLET>_RUNNER` environment variable.  This is useful for running
101    /// binaries that can't be launched directly, such as cross-compiled binaries. When using
102    /// this method with [cross](https://github.com/cross-rs/cross), no extra configuration is
103    /// needed.
104    ///
105    /// Cargo support:
106    /// - `>1.94`: works
107    /// - `>=1.91,<=1.93`: works with default `build-dir`
108    /// - `<=1.92`: works
109    ///
110    /// # Panic
111    ///
112    /// Panicks if no binary is found
113    ///
114    /// # Examples
115    ///
116    /// ```rust,no_run
117    /// use assert_cmd::prelude::*;
118    /// use assert_cmd::pkg_name;
119    ///
120    /// use std::process::Command;
121    ///
122    /// let mut cmd = Command::cargo_bin(pkg_name!())
123    ///     .unwrap();
124    /// let output = cmd.unwrap();
125    /// println!("{:?}", output);
126    /// ```
127    ///
128    /// ```rust,no_run
129    /// use assert_cmd::prelude::*;
130    ///
131    /// use std::process::Command;
132    ///
133    /// let mut cmd = Command::cargo_bin("bin_fixture")
134    ///     .unwrap();
135    /// let output = cmd.unwrap();
136    /// println!("{:?}", output);
137    /// ```
138    ///
139    /// [`Command`]: std::process::Command
140    fn cargo_bin<S: AsRef<str>>(name: S) -> Result<Self, CargoError>;
141}
142
143impl CommandCargoExt for crate::cmd::Command {
144    fn cargo_bin<S: AsRef<str>>(name: S) -> Result<Self, CargoError> {
145        crate::cmd::Command::cargo_bin(name)
146    }
147}
148
149impl CommandCargoExt for process::Command {
150    fn cargo_bin<S: AsRef<str>>(name: S) -> Result<Self, CargoError> {
151        cargo_bin_cmd(name)
152    }
153}
154
155pub(crate) fn cargo_bin_cmd<S: AsRef<str>>(name: S) -> Result<process::Command, CargoError> {
156    let path = cargo_bin(name);
157    if path.is_file() {
158        if let Some(runner) = cargo_runner() {
159            let mut cmd = process::Command::new(&runner[0]);
160            cmd.args(&runner[1..]).arg(path);
161            Ok(cmd)
162        } else {
163            Ok(process::Command::new(path))
164        }
165    } else {
166        Err(CargoError::with_cause(NotFoundError { path }))
167    }
168}
169
170pub(crate) fn cargo_runner() -> Option<Vec<String>> {
171    let runner_env = format!(
172        "CARGO_TARGET_{}_RUNNER",
173        CURRENT_TARGET.replace('-', "_").to_uppercase()
174    );
175    let runner = env::var(runner_env).ok()?;
176    Some(runner.split(' ').map(str::to_string).collect())
177}
178
179/// Error when finding crate binary.
180#[derive(Debug)]
181pub struct CargoError {
182    cause: Option<Box<dyn Error + Send + Sync + 'static>>,
183}
184
185impl CargoError {
186    /// Wrap the underlying error for passing up.
187    pub fn with_cause<E>(cause: E) -> Self
188    where
189        E: Error + Send + Sync + 'static,
190    {
191        let cause = Box::new(cause);
192        Self { cause: Some(cause) }
193    }
194}
195
196impl Error for CargoError {}
197
198impl fmt::Display for CargoError {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        if let Some(ref cause) = self.cause {
201            writeln!(f, "Cause: {cause}")?;
202        }
203        Ok(())
204    }
205}
206
207/// Error when finding crate binary.
208#[derive(Debug)]
209struct NotFoundError {
210    path: path::PathBuf,
211}
212
213impl Error for NotFoundError {}
214
215impl fmt::Display for NotFoundError {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        writeln!(f, "Cargo command not found: {}", self.path.display())
218    }
219}
220
221/// Look up the path to a cargo-built binary within an integration test
222///
223/// Cargo support:
224/// - `>1.94`: works
225/// - `>=1.91,<=1.93`: works with default `build-dir`
226/// - `<=1.92`: works
227///
228/// # Panic
229///
230/// Panicks if no binary is found
231pub fn cargo_bin<S: AsRef<str>>(name: S) -> path::PathBuf {
232    cargo_bin_str(name.as_ref())
233}
234
235fn cargo_bin_str(name: &str) -> path::PathBuf {
236    let env_var = format!("{CARGO_BIN_EXE_}{name}");
237    env::var_os(env_var)
238        .map(|p| p.into())
239        .or_else(|| legacy_cargo_bin(name))
240        .unwrap_or_else(|| missing_cargo_bin(name))
241}
242
243const CARGO_BIN_EXE_: &str = "CARGO_BIN_EXE_";
244
245fn missing_cargo_bin(name: &str) -> ! {
246    let possible_names: Vec<_> = env::vars_os()
247        .filter_map(|(k, _)| k.into_string().ok())
248        .filter_map(|k| k.strip_prefix(CARGO_BIN_EXE_).map(|s| s.to_owned()))
249        .collect();
250    if possible_names.is_empty() {
251        panic!("`CARGO_BIN_EXE_{name}` is unset
252help: if this is running within a unit test, move it to an integration test to gain access to `CARGO_BIN_EXE_{name}`")
253    } else {
254        let mut names = String::new();
255        for (i, name) in possible_names.iter().enumerate() {
256            use std::fmt::Write as _;
257            if i != 0 {
258                let _ = write!(&mut names, ", ");
259            }
260            let _ = write!(&mut names, "\"{name}\"");
261        }
262        panic!(
263            "`CARGO_BIN_EXE_{name}` is unset
264help: available binary names are {names}"
265        )
266    }
267}
268
269fn legacy_cargo_bin(name: &str) -> Option<path::PathBuf> {
270    let target_dir = target_dir()?;
271    let bin_path = target_dir.join(format!("{}{}", name, env::consts::EXE_SUFFIX));
272    if !bin_path.exists() {
273        return None;
274    }
275    Some(bin_path)
276}
277
278// Adapted from
279// https://github.com/rust-lang/cargo/blob/485670b3983b52289a2f353d589c57fae2f60f82/tests/testsuite/support/mod.rs#L507
280fn target_dir() -> Option<path::PathBuf> {
281    let mut path = env::current_exe().ok()?;
282    let _test_bin_name = path.pop();
283    if path.ends_with("deps") {
284        let _deps = path.pop();
285    }
286    Some(path)
287}
288
289/// The current process' target triplet.
290const CURRENT_TARGET: &str = include_str!(concat!(env!("OUT_DIR"), "/current_target.txt"));
291
292#[test]
293#[should_panic = "`CARGO_BIN_EXE_non-existent` is unset
294help: if this is running within a unit test, move it to an integration test to gain access to `CARGO_BIN_EXE_non-existent`"]
295fn cargo_bin_in_unit_test() {
296    cargo_bin("non-existent");
297}