Cargo runs any executable named cargo-something that is on your PATH as cargo something, with no registration, no plugin API and no manifest key. That is the entire extension mechanism, and it is why cargo-edit, cargo-nextest and cargo-llvm-cov all feel built in. This guide writes one from nothing on Rust 1.90 with edition 2024, clap 4.6, cargo_metadata 0.23 and anyhow 1. It counts lines of Rust per package in a workspace, which is small enough to read in one sitting and real enough to hit every problem a bigger tool will: the doubled argument, the manifest lookup, error messages and installation.
How cargo finds your command
When you type cargo lines, Cargo checks its built-in commands first. If none matches, it searches PATH for an executable called cargo-lines and runs it. You can see every external command it finds:
cargo --list ...
lines
...Two details decide how you write the program. First, Cargo runs the binary with the subcommand name as its first argument, so cargo lines --package foo executes cargo-lines lines --package foo. Your argument parser has to expect that extra lines. Second, Cargo sets some environment variables for the child, notably CARGO, the path of the Cargo binary that launched you. Use it instead of the literal string "cargo" when you run Cargo yourself, so the tool follows the same toolchain the user selected with cargo +nightly lines.
Create the crate
The package name must start with cargo-, because that becomes the binary name.
cargo new --vcs none cargo-lines
cd cargo-lines
cargo add clap --features derive
cargo add cargo_metadata anyhow[package]
name = "cargo-lines"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
cargo_metadata = "0.23.1"
clap = { version = "4.6.7", features = ["derive"] }The doubled argument, handled in the type
The trick every Cargo subcommand uses is to make the parser's top level an enum with a single variant named after the command. Cargo's extra lines argument then selects the variant, and --help and error messages still read correctly. The attribute bin_name = "cargo" makes usage lines say cargo lines instead of cargo-lines lines.
use anyhow::{Context, Result};
use cargo_metadata::MetadataCommand;
use clap::{Args, Parser};
use std::{fs, path::Path};
#[derive(Parser)]
#[command(name = "cargo", bin_name = "cargo")]
enum Cargo {
Lines(LinesArgs),
}
#[derive(Args)]
#[command(version, about = "Count lines of Rust per workspace member")]
struct LinesArgs {
/// Path to Cargo.toml (defaults to the current project)
#[arg(long)]
manifest_path: Option<std::path::PathBuf>,
/// Count only this package
#[arg(short, long)]
package: Option<String>,
}
fn count(dir: &Path) -> Result<usize> {
let mut total = 0;
for entry in fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
let path = entry?.path();
if path.is_dir() {
total += count(&path)?;
} else if path.extension().is_some_and(|e| e == "rs") {
total += fs::read_to_string(&path)?.lines().count();
}
}
Ok(total)
}
fn main() -> Result<()> {
let Cargo::Lines(args) = Cargo::parse();
let mut cmd = MetadataCommand::new();
if let Some(p) = &args.manifest_path {
cmd.manifest_path(p);
}
let meta = cmd.no_deps().exec().context("running cargo metadata")?;
for pkg in meta.packages {
if args.package.as_deref().is_some_and(|p| p != pkg.name.as_str()) {
continue;
}
let root = pkg.manifest_path.parent().context("manifest has a parent")?;
println!("{:>6} {}", count(root.join("src").as_std_path())?, pkg.name);
}
Ok(())
}Three choices in that file are worth defending. The let Cargo::Lines(args) = Cargo::parse(); pattern is irrefutable because the enum has one variant, so no match is needed, and when you add a second command it becomes a compile error that points at every place to update. is_some_and replaces the older map_or(false, ...) and reads as the filter it is. And MetadataCommand is the part to copy into every tool of this kind, so it gets its own section.
Ask cargo about the project, do not parse Cargo.toml
The beginner's version of this tool opens Cargo.toml, reads [workspace] members, expands the globs and hopes. It breaks on workspace inheritance, on exclude, on path dependencies and on virtual manifests. Cargo already knows the answer and will print it as JSON:
cargo metadata --format-version 1 --no-depsThe cargo_metadata crate runs that command (using the CARGO variable, so it picks the right toolchain) and gives you typed structs: packages, targets, features, resolved dependencies and the workspace root. no_deps() asks for workspace members only. Without it the output includes every crate in the dependency graph, which is slow on a large project and not what a line counter wants.
The --manifest-path flag is a convention. Every built-in Cargo command accepts it, so a user who runs cargo lines --manifest-path other/Cargo.toml expects it to work. Following the convention costs three lines and prevents a class of bug reports.
Run it
cargo build
export PATH="$PWD/target/debug:$PATH"
cd ../ws
cargo lines 4 wordstat
8 wordstat-coreAdding the debug directory to PATH is the fastest development loop: rebuild, then call cargo lines from any project. Filter to one package:
cargo lines --package wordstat 4 wordstatAnd the help text reads as a native command:
Count lines of Rust per workspace member
Usage: cargo lines [OPTIONS]
Options:
--manifest-path <MANIFEST_PATH> Path to Cargo.toml (defaults to the current project)
-p, --package <PACKAGE> Count only this package
-h, --help Print help
-V, --version Print versionWhat happens when the user is in the wrong place
A tool earns trust in its failure messages. Run it outside any project:
cd /tmp
cargo linesError: running cargo metadata
Caused by:
`cargo metadata` exited with an error: error: could not find `Cargo.toml` in `/private/tmp` or any parent directoryThat is a good message, and it comes almost for free. The first line is our .context("running cargo metadata"), and the cause is Cargo's own sentence, which already tells the user what to do. Notice what we did not do: we did not catch the error and print "something went wrong". anyhow keeps the chain, and returning the error from main prints it and exits with status 1. The same applies to a bad path:
cargo lines --manifest-path /nonexistent/Cargo.tomlError: running cargo metadata
Caused by:
`cargo metadata` exited with an error: error: manifest path `/nonexistent/Cargo.toml` does not existIf your tool's whole job is to wrap another program, forward that program's words. They are written by people who knew the cases better than you will.
The mistake that costs an afternoon: running it as the wrong name
Run the binary directly and the doubled argument shows up, because nobody inserted lines:
./target/debug/cargo-lines --package wordstaterror: unexpected argument '--package' found
Usage: cargo <COMMAND>The usage line shows what the parser wanted: a command name first. If you want the direct form to work too, accept both by matching the second argument before parsing. Most tools do not bother, and the clearer rule is that these binaries are meant to be run through Cargo. When debugging under a debugger or cargo run, pass the name explicitly:
cargo run -- lines --package wordstatAdd machine-readable output
A tool that prints a table is a tool a person reads. The next user will want to pipe it into a dashboard or a CI gate, and scraping columns of spaces is how scripts break the first time someone renames a package. Add a --json flag. The row type gets serde::Serialize, the printing loop collects rows first and then chooses a format:
#[derive(serde::Serialize)]
struct Row {
package: String,
lines: usize,
}
// in main, after the metadata call:
let mut rows = Vec::new();
for pkg in meta.packages {
if args.package.as_deref().is_some_and(|p| p != pkg.name.as_str()) {
continue;
}
let root = pkg.manifest_path.parent().context("manifest has a parent")?;
let lines = count(root.join("src").as_std_path())?;
rows.push(Row { package: pkg.name.to_string(), lines });
}
if args.json {
println!("{}", serde_json::to_string_pretty(&rows)?);
} else {
for r in &rows {
println!("{:>6} {}", r.lines, r.package);
}
}cargo add serde --features derive
cargo add serde_json
cargo lines --json[
{
"package": "wordstat",
"lines": 4
},
{
"package": "wordstat-core",
"lines": 8
}
]Collecting before printing is the structural change that matters. It separates "find the data" from "show the data", which is also why the function can be tested without capturing standard output. The table form stays the default because people type the command far more often than scripts do, and it pipes cleanly into the standard tools:
cargo lines | sort -n | tail -1 8 wordstat-coreOnce a script depends on the JSON shape, the field names are a promise. Rename lines to line_count and every consumer breaks. Treat the keys the way you treat a public function signature, and add a field rather than changing one.
Calling cargo from your tool
Many subcommands do not read metadata; they run a build and inspect the result. When you do that, never hardcode "cargo". Cargo sets CARGO to the exact binary that launched you, which matters because a user who runs cargo +nightly lines expects your inner build to use nightly too. The pattern:
use std::process::Command;
pub fn check(manifest: Option<&std::path::Path>) -> std::io::Result<std::process::ExitStatus> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args(["check", "--message-format=short", "--quiet"]);
if let Some(m) = manifest {
cmd.arg("--manifest-path").arg(m);
}
cmd.status()
}The fallback to the plain name covers the case where someone runs the binary directly. --message-format=json is the better choice when you want to parse compiler diagnostics: Cargo prints one JSON object per line, and the cargo_metadata::Message iterator turns each into a typed value, so you can count warnings, collect artifact paths or fail on a specific lint without regular expressions over human-oriented text.
Pass the user's extra arguments through. Cargo's own convention is that everything after -- belongs to the program being run, so declare a trailing argument list and forward it verbatim:
#[derive(clap::Args)]
struct RunArgs {
/// Arguments passed through to the program, after `--`
#[arg(last = true)]
rest: Vec<String>,
}With last = true, cargo mytool -- --flag value delivers ["--flag", "value"] without clap trying to interpret them as its own options. Without it, the first unknown flag aborts parsing with an error that blames your tool for something that was not meant for it.
Respect the environment cargo gives you
Cargo exports a handful of variables to every external command and to build scripts. The ones worth knowing are CARGO (the binary), CARGO_MANIFEST_DIR when the command runs under cargo run, and CARGO_TERM_COLOR, which holds the user's color preference. Reading the last one takes a few lines and keeps your output consistent with the compiler's:
use std::io::IsTerminal;
pub fn use_color() -> bool {
match std::env::var("CARGO_TERM_COLOR").as_deref() {
Ok("always") => true,
Ok("never") => false,
_ => std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
}
}Auto is the default: color on when standard output is a terminal, off when it is piped, and off whenever NO_COLOR is set. The same rule is why cargo lines | sort printed plain text in the earlier example. A tool that emits escape codes into a pipe corrupts the next program's input and gets reported as a bug against the wrong project.
Write the tests
Split the logic from the parsing so the interesting part is testable without a subprocess. The count function takes a directory, so a temporary directory is the whole fixture. Add tempfile as a dev dependency:
cargo add --dev tempfile#[cfg(test)]
mod tests {
use super::count;
use std::fs;
#[test]
fn counts_nested_rust_files_only() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.rs"), "fn a() {}\nfn b() {}\n").unwrap();
fs::create_dir(dir.path().join("inner")).unwrap();
fs::write(dir.path().join("inner/c.rs"), "fn c() {}\n").unwrap();
fs::write(dir.path().join("notes.txt"), "one\ntwo\nthree\n").unwrap();
assert_eq!(count(dir.path()).unwrap(), 3);
}
#[test]
fn missing_directory_names_the_path() {
let err = count(std::path::Path::new("/definitely/not/here")).unwrap_err();
assert!(err.to_string().contains("/definitely/not/here"));
}
}The second test pins the error message, which is a contract with the user. For the end-to-end behavior, assert_cmd can run the real binary against a fixture project, as covered in testing CLIs. Because the binary expects its own name as the first argument, pass ["lines"] in those tests.
Test the real binary through cargo's front door
The unit tests above cover count. They cannot catch the bug that matters most for this kind of tool: the argument handling that only exists because Cargo inserts the command name. An integration test runs the compiled binary the way Cargo would, with lines as the first argument, against a fixture project it builds in a temporary directory:
cargo add --dev assert_cmd predicates tempfileuse assert_cmd::Command;
use predicates::str::contains;
use std::fs;
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}\n\n\n").unwrap();
dir
}
#[test]
fn prints_a_row_per_package() {
let dir = fixture();
Command::cargo_bin("cargo-lines")
.unwrap()
.arg("lines")
.current_dir(dir.path())
.assert()
.success()
.stdout(contains("demo"));
}
#[test]
fn json_flag_emits_parseable_output() {
let dir = fixture();
let out = Command::cargo_bin("cargo-lines")
.unwrap()
.args(["lines", "--json"])
.current_dir(dir.path())
.output()
.unwrap();
let rows: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(rows[0]["lines"], 3);
}
#[test]
fn outside_a_project_the_error_is_cargos_own() {
let dir = tempfile::tempdir().unwrap();
Command::cargo_bin("cargo-lines")
.unwrap()
.arg("lines")
.current_dir(dir.path())
.assert()
.failure()
.stderr(contains("could not find `Cargo.toml`"));
}Three tests, three promises: it works through Cargo's calling convention, its JSON parses, and a missing project produces Cargo's sentence rather than a panic. Add serde_json as a dev dependency too, or reuse the normal one. Run them with cargo test; Cargo builds the binary before the integration tests start, so cargo_bin always finds a fresh one.
Document it where Cargo users look
People find external commands in three places: cargo --list, the crate's README and cargo help lines. The last one is worth a moment. Cargo turns cargo help <name> into a call of cargo-lines lines --help, so the clap help text you already wrote is the documentation, and it is the first thing a new user reads. Keep the one-line summary under about 70 characters, write each option description as a phrase without a final period, and name the default where there is one, as the --manifest-path description does.
The README needs only four things: what the command prints, one real run with its output, the install line, and the Rust version it supports. State the version in the sentence and enforce it with rust-version = "1.85" in Cargo.toml, so a user on an older compiler gets Cargo's clear refusal instead of a confusing syntax error from a newer language feature. If you track the supported version in CI, include the job that builds with exactly that toolchain, because a promise nobody tests drifts within two releases.
A fixture that fails on purpose
The fixture package in those tests has three lines in src/main.rs: one function and two blank lines. That is deliberate. str::lines counts blank lines, so the JSON test asserts 3, and anyone who later changes the tool to skip blanks has to change a test and say why. A counter is a tool whose definition of its own unit is its entire behavior. Decide whether a "line" includes blanks, comments and generated code, write it into the help text, and let one test hold the line. Real line counters such as tokei split the total into code, comments and blanks for exactly this reason, and that split is the natural next feature if you grow this tool.
The same discipline applies to the fixture's package name. The third test would pass for the wrong reason if the temporary directory happened to contain a parent manifest, because Cargo walks upward looking for one. Temporary directories live under the system temp path, which has no Cargo.toml above it on a normal machine, but a CI runner that checks out into /tmp can break the assumption. If the test is ever flaky, set the working directory somewhere you control and assert on the error text, which the test already does.
Install it
Three ways to put the binary on someone's PATH, in order of effort:
cargo install --path .
cargo install cargo-lines
cargo install --git https://github.com/example/cargo-linesThe first installs from a checkout into ~/.cargo/bin, which is already on PATH for anyone with Rust installed. The second needs the crate on crates.io, where the name cargo-lines must be free. The third works from any Git repository. For users without a toolchain, prebuilt binaries attached to a GitHub release are friendlier, and cargo binstall can fetch those if you add a [package.metadata.binstall] section. The release side is covered in releasing a CLI with cargo dist.
Check the uninstall path while you are there. cargo uninstall cargo-lines removes it; if you shipped a name that clashes with an existing tool, users will find out the hard way when two cargo-lines binaries fight over one file.
Conventions that make it feel native
- Accept
--manifest-pathand-p/--packagewith the meanings Cargo gives them. - Honor
--quiet,--verboseand theCARGO_TERM_COLORenvironment variable if you print status or colors. - Exit with status 0 on success and non-zero on failure, so the tool works in CI scripts and with
&&. - Print machine-readable data on standard output and human commentary on standard error, so
cargo lines | sort -nworks. - Use the
CARGOenvironment variable when you spawn Cargo, andRUSTCwhen you spawn the compiler.
The --manifest-path point deserves the most attention. A tool that only works from a project's root directory looks fine in the author's terminal and fails in the CI step that sets working-directory differently.
Extending it: use the target list, not a guess
The version above counts src/. A package can also keep code in tests/, benches/ and examples/, and a binary can point path anywhere. cargo_metadata exposes the truth in pkg.targets, each with a src_path. Counting from the targets covers custom layouts, but it counts a file once per target that lists it, and a shared module can then appear twice. Collect the paths in a BTreeSet before counting:
use std::collections::BTreeSet;
use std::path::PathBuf;
fn source_dirs(pkg: &cargo_metadata::Package) -> BTreeSet<PathBuf> {
pkg.targets
.iter()
.filter_map(|t| t.src_path.parent())
.map(|p| p.as_std_path().to_path_buf())
.collect()
}Now a library and a binary that both live under src/ are counted once, and a package with an examples/ directory is counted there too. You will still double count a directory that is an ancestor of another, so decide whether that matters before you publish numbers. A line counter that says 4,000 when the answer is 3,200 is worse than one that says nothing.
Where this approach is the wrong one
A subcommand is the right shape for something a user runs deliberately, from a project directory, on that project. It is the wrong shape in three cases.
- Build-time work. Code generation or checks that must happen on every
cargo buildbelong in abuild.rs, or in a proc macro, because a subcommand only runs when someone remembers to call it. - Per-repository tasks. A project's own lint or release steps are better as an
xtaskcrate: a workspace member run withcargo run -p xtaskor acargo xtaskalias in.cargo/config.toml. It is versioned with the repository, needs no install and cannot drift from the code it serves. - Anything stateful or long-running. A daemon that watches files is a service with a Cargo-shaped front door; model it as one.
The xtask pattern deserves a mention because it is often what people want when they search for this topic. If the only user is your own team, skip the naming convention and the installation, and add a workspace member plus one alias:
[alias]
xtask = "run --package xtask --"Then cargo xtask lines runs the tool from the checkout, built by the same toolchain as everything else.
Publish checklist
Before cargo publish, check four things. The crate name is free and does not shadow an existing tool. The description, license and repository keys are set. cargo install --path . works from a clean clone. And --help reads like Cargo's own. Cargo's own subcommands document themselves in sentence case without a trailing period on the summary line; match that and your tool will not stand out for the wrong reasons.



