DEV Community

Reamid
Reamid

Posted on AI-assisted

How to Build and Test a Cargo Subcommand

Cargo has a quiet superpower: cargo <anything> is an extension point. Put a binary named cargo-foo on PATH and cargo foo works — that's how cargo-edit, cargo-expand, and dozens of other tools exist.

In this tutorial we'll walk through what it takes to build one of these subcommands so it feels like part of Cargo, and — the part most tutorials skip — how to test it when your tool's job is to shell out to Cargo, compile code, and hit a package registry. Our running example is cargo-dlx, a real subcommand that downloads, compiles, and runs a Rust binary without permanently installing it (think npx or uvx for Rust):

cargo dlx ripgrep@14.1.1 --help
Enter fullscreen mode Exit fullscreen mode

Everything below is taken from its actual source, so you can follow along in the repo.

Step 1: The subcommand contract

The mechanics are simple, but there are three details that separate a toy from a tool.

Strip the subcommand name from argv

When a user runs cargo dlx ripgrep, Cargo invokes your binary with argv:

["cargo-dlx", "dlx", "ripgrep"]
Enter fullscreen mode Exit fullscreen mode

That leading "dlx" is a convention inherited from git's subcommands. If you feed argv straight into clap, dlx gets parsed as your first positional argument and everything breaks. You need to strip it — and while you're at it, keep the binary working when invoked directly as cargo-dlx ripgrep (no prefix). cargo-dlx does this with a Cli::normalize_raw_args helper called before Cli::parse_from, and pins the behavior with a test:

#[cargo_test]
fn strips_cargo_subcommand_prefix() {
    let p = project().build();

    p.cargo_dlx("dlx ripgrep@v14.1.1") // note the "dlx" prefix, as cargo would pass it
        .env("CARGO", "cargo")
        .with_status(1)
        .with_stderr_data(str![[r#"
[ERROR] the version provided, `v14.1.1` is not a valid SemVer requirement

[HELP] try changing the version to `14.1.1`

"#]])
        .run();
}
Enter fullscreen mode Exit fullscreen mode

Respect $CARGO

Your subcommand will often need to call back into Cargo itself (cargo-dlx shells out to cargo install). When it does, use the CARGO environment variable if set, and fall back to cargo on PATH:

cargo-dlx invokes the Cargo executable from $CARGO when set, otherwise cargo from PATH.

This matters more than it looks: rustup proxies, custom toolchains, and your own tests all rely on it.

Exit codes and stderr are interface, not decoration

Propagate the child process's exit code so your subcommand composes in scripts. cargo-dlx's main ends with:

match cargo_dlx::execute(&cmd) {
    Ok(cargo_dlx::Execution::Completed) => {}
    Ok(cargo_dlx::Execution::ChildExited(code)) => std::process::exit(code),
    Err(error) => {
        eprintln!("error: {error}");
        std::process::exit(error.exit_code());
    }
}
Enter fullscreen mode Exit fullscreen mode

With that, cargo dlx some-linter && echo clean behaves exactly like the underlying tool would. Diagnostics go to stderr, results go to stdout — the usual rules, but worth stating because a subcommand that violates them breaks every pipeline it's used in.

Step 2: Design to fit in

Anyone can parse args. The harder problem is deciding what your flags, syntax, and error messages should be. cargo-dlx's approach is worth stealing: its DESIGN.md states up front

This is a polyfill for what could be merged into Cargo. Design decisions should align with existing design elements in Cargo.

You don't have to aim for upstreaming, but the underlying principle generalizes: when a design element already exists in Cargo, reuse it; only invent when you must. Concretely, in cargo-dlx:

  cargo dlx ripgrep@14.1.1
  cargo dlx 'git+https://github.com/owner/repo.git?rev=<sha>#my-tool'
  cargo dlx 'sparse+https://index.crates.io/#ripgrep@14.1.1'
  cargo dlx 'path+file:///absolute/path/to/my-tool#my-tool@0.1.0'
Enter fullscreen mode Exit fullscreen mode
  • Flags keep their Cargo names and meanings: --bin, --example, --profile, --features, --locked, --offline, --frozen.
  • Multi-binary packages follow cargo run semantics: a single [[bin]] is the default; default-run resolves ambiguity; otherwise error and list the available binaries.

The other thing worth copying is the documentation habit. cargo-dlx's DESIGN.md records not just decisions but alternatives considered and prior art (yarn dlx, pnpm dlx, npm exec, bunx, pipx run, uvx, deno run), plus a list of open questions. For a CLI, the design doc is where you answer "why does it work this way?" once, instead of in every issue thread.

Step 3: Test it with Cargo's own harness

Here's where most subcommand projects give up and write shell scripts. Testing a tool that compiles and runs other Rust code means you need temp projects, a package source, a compiler, and output assertions — per test.

The trick: Cargo's own testsuite infrastructure is published as crates, and you can use it from any project:

[dev-dependencies]
cargo-test-macro = "0.4.9"
cargo-test-support = "0.10.0"
cargo-util = "0.2.27"
snapbox = "0.6.4"
Enter fullscreen mode Exit fullscreen mode

This is the same harness that runs Cargo's own thousands of integration tests.

A hermetic Cargo universe per test

Annotate tests with #[cargo_test] and every test gets an isolated environment: a temp project directory, a fake CARGO_HOME, and — critically — a local test registry, so you never touch crates.io. Publishing a fixture crate is three lines:

#[cargo_test]
fn runs_registry_binary() {
    Package::new("dlx-hello", "0.1.0")
        .file("src/main.rs", r#"
fn main() {
    println!("hello from cargo-dlx");
}
"#)
        .publish(); // publishes to the in-test registry

    let p = project().build();

    p.cargo_dlx("dlx-hello")
        .with_stdout_data(str![[r#"
hello from cargo-dlx

"#]])
        .run();
}
Enter fullscreen mode Exit fullscreen mode

cargo dlx then resolves, downloads, compiles, and runs that crate with zero network access. Other source kinds get their own fixtures: git::new_repo(...) builds a real local git repo for git+ specs, and a second project().at("...") gives you a path source for file:// specs. Whatever your subcommand consumes, there's a constructor for it.

Wire up a tiny helper

The ergonomic core is a small extension trait over cargo_test_support::Project:

pub trait ProjectExt {
    /// Creates an `Execs` instance to run the `cargo-dlx` binary
    fn cargo_dlx(&self, cmd: &str) -> Execs;
    /// Creates an `Execs` instance to run the globally installed `cargo` command
    fn cargo_global(&self, cmd: &str) -> Execs;
}
Enter fullscreen mode Exit fullscreen mode

Note the second method: it runs real Cargo inside the same fixture. That's useful whenever you want to compare your subcommand's behavior against Cargo's own on identical input — for example, checking that your multi-binary error condition matches what cargo run does with the same project:

// What does cargo run do with this fixture?
p.cargo_global("run")
    .with_status(101)
    .with_stderr_data(str![[r#"
[ERROR] `cargo run` could not determine which binary to run. Use the `--bin` option to specify a binary, or the `default-run` manifest key.
available binaries: goodbye, hello

"#]])
    .run();

// And does the subcommand fail on the same condition?
p.cargo_dlx(&format!("path+{}#dlx-path-source", p.root().to_url()))
    .with_status(1)
    .with_stderr_data(str![[r#"
...
[ERROR] `cargo run` could not determine which binary to run
[HELP] specify the binary with `--bin` option
available binaries: goodbye, hello

"#]])
    .run();
Enter fullscreen mode Exit fullscreen mode

Snapshot the output, wildcards included

Assertions come from snapbox: .with_status(42), .with_stdout_data(...), .env(...), .run(). The str![[...]] macro lets you write expected output as a literal block, and ... acts as a line wildcard — essential when Cargo's own build chatter appears interleaved with the output you care about:

p.cargo_dlx("dlx-exit")
    .with_status(42)
    .with_stderr_data(str![[r#"
...
dlx-exit failed intentionally

"#]])
    .run();
Enter fullscreen mode Exit fullscreen mode

For whole-UI surfaces like --help, cargo-test-support ships compare::assert_ui(), which snapshots the full styled terminal output — colors, hyperlinks and all — against a checked-in stdout.term.svg:

#[cargo_test]
fn case() {
    snapbox::cmd::Command::cargo_ui()
        .arg("--help")
        .assert()
        .success()
        .stdout_eq(file!["stdout.term.svg"])
        .stderr_eq(str![""]);
}
Enter fullscreen mode Exit fullscreen mode

When your clap definitions change, SNAPSHOTS=overwrite cargo test regenerates the snapshot, and the rendered diff shows up in code review like any other change.

Step 4: Test the side effects, not just the output

A subcommand's real footprint is often invisible in the terminal: temp directories, caches, lock files. cargo-dlx installs binaries into a temporary root, caches build artifacts under ~/.cargo-dlx/build, and offers --clear to clean up. Roughly half its testsuite is dedicated to proving that machinery works: every combination of CARGO_DLX_ROOT / CARGO_DLX_TEMP / CARGO_DLX_BUILD / --cache-dir, including the hostile case where $HOME doesn't exist at all.

The assertions are plain filesystem checks after the command runs:

fn temp_dir_is_empty(path: &std::path::Path) -> bool { /* ... */ }

fn target_cache_dir_names(path: &Path) -> Vec<String> { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The rule of thumb: stdout proves the happy path; the directory tree proves the cleanup. If your tool creates, caches, or deletes files, assert on the files.

Recap

Building a cargo subcommand that holds up in the real world comes down to four habits:

  1. Honor the contract — strip the subcommand-name argument, respect $CARGO, propagate exit codes, keep diagnostics on stderr.
  2. Reuse Cargo's design vocabulary — pkgid-specs, flag names, and semantics your users already know; write down the decisions and alternatives in a DESIGN.md.
  3. Test with Cargo's own harness — cargo-test-support, cargo-test-macro, and snapbox give you hermetic projects, a local registry, git/path fixtures, and snapshot assertions.
  4. Assert on side effects — caches, temp dirs, and cleanup logic deserve the same rigor as stdout.

All the code in this post is from cargo-dlx (cargo install cargo-dlx if you want to try it). Its tests/testsuite/ directory doubles as a worked example of everything above.

Top comments (0)