197. A command-line interface
The engine is a library. People reach it through the terminal, as a list of words, two output streams and one small number when it finishes. Here is the Rust CLI that turns those into a compile: a hand-written parser, two kinds of error, and a help text it shares with TypeScript.
- Say what a CLI receives from the shell and what it gives back, including the exit status.
- Read the Rust CLI's hand-written argument parser and write one in the same style.
- Tell a usage error from any other error, and say why only one prints the help.
- Say what clap would change, and why parity makes that a real cost here.
A library needs a front door
Part XI built a library, compile(source, &JsPlugin, opts). Nobody
types that. They type this:
$ viscosity explain particles.js
The crate that answers is engine/rust/crates/cli, built into a binary
called viscosity. Most of its main.rs is plumbing between the shell
and the compiler. That plumbing has conventions older than Rust, and
Viscosity adds one rule: the Rust CLI must behave like the TypeScript
one.
A CLI is a counter at the post office. You slide a form under the glass (the words you typed). The clerk hands back a receipt through one slot and complaints through another. And as you leave, a light over the door turns green or red, so the next person in the queue (often a script) knows how it went without reading anything.
A command-line interface (CLI) is a program driven from a terminal. It receives a list of strings, its arguments, from the shell. It writes results to standard output (stdout) and messages to standard error (stderr), so a user can send the results to a file and still see the errors[1]. When it ends, it returns an exit status to whoever started it: 0 for success, and by convention anything else for failure[1].
In Rust the arguments come from std::env::args(), the program’s path
first[2], and main can return an
ExitCode, with SUCCESS and FAILURE built in[3].
A loop and a match
Track B’s outline planned this chapter around clap, the usual Rust
library for parsing arguments. The real CLI doesn’t use it. Its parser
is a while loop with one match arm per flag, simplified here from
parse_args in main.rs:
let mut i = 1; // argv[0] is the command
while i < argv.len() {
let a = argv[i].as_str();
let mut next = || {
i += 1;
argv.get(i).cloned().ok_or_else(
|| Usage(format!(
"{a} needs a value"
)),
)
};
match a {
"-o" | "--out" => {
args.out = next()?
}
"--frames" => args.frames = true,
// ... --mode, --bounds, --jobs ...
_ if !a.starts_with('-') => {
args.files.push(a.to_string())
}
_ => return Err(Usage(format!(
"unknown option {a}"
))),
}
i += 1;
}
The closure next is the one clever part. It borrows i mutably,
moves it on to the flag’s value and returns it, or a usage error if the
line ended; ? passes the error straight up. Words without a dash are
files, so flags and files can come in any order:
Argument parsing turns the list of words into typed options: here,
an Args struct with the command, the files, the output path and a
CompileOptions. A flag like --frames is a switch. An
option like -o out/ takes the next word as its value. The other
words are positional arguments: the command, then the files.
Two kinds of error
Some failures are about the command line itself: an unknown flag, a missing value, no files. Help is useful then. A missing file or a syntax error is different, and 27 lines of help would bury the one line that matters. So the error type makes the distinction:
/// Only a usage error (the command line
/// itself was wrong) gets the help text.
enum CliError {
Usage(String),
Other(String),
}
impl From<String> for CliError {
fn from(e: String) -> Self {
CliError::Other(e)
}
}
With the From impl, ? turns a plain String error into Other
(chapter 144). It all ends in one match in main:
match run(&argv, &mut out, &mut err) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(CliError::Usage(e)) => {
// message, blank line, HELP
ExitCode::FAILURE
}
Err(CliError::Other(e)) => {
// "viscosity: {e}" only
ExitCode::FAILURE
}
}
(The write! calls are shortened to comments.) I ran the release
build: --mode fast, --bogus, a missing file and a syntax error all
exit 1, and only the first two print the help. This program plays the
same game:
// A sketch of main in crates/cli: two
// kinds of error, one exit status.
#[derive(Debug)]
enum CliError {
Usage(String),
Other(String),
}
use CliError::*;
fn run(
argv: &[&str],
) -> Result<String, CliError> {
if argv.len() < 2 {
return Err(Usage(
"missing <file>".into(),
));
}
if argv[0] != "explain" {
return Err(Usage(format!(
"unknown command \"{}\"",
argv[0]
)));
}
let f = argv[1];
if f.starts_with('-') {
return Err(Usage(format!(
"unknown option {f}"
)));
}
if f == "gone.js" {
let e = "No such file or \
directory (os error 2)";
return Err(Other(format!(
"cannot read {f}: {e}"
)));
}
Ok(format!("{f}: no arrays found"))
}
// What main prints; its exit status.
fn report(argv: &[&str]) -> u8 {
let e = match run(argv) {
Ok(out) => {
println!("out| {out}");
return 0;
}
Err(e) => e,
};
let (msg, usage) = match e {
Usage(m) => (m, true),
Other(m) => (m, false),
};
println!("err| viscosity: {msg}");
if usage {
println!("err|");
println!("err| (help text)");
}
1
}
fn main() {
let tries: [&[&str]; 5] = [
&["explain", "a.js"],
&["explian", "a.js"],
&["explain", "--fast"],
&["explain"],
&["explain", "gone.js"],
];
for argv in tries {
let line = argv.join(" ");
println!("$ viscosity {line}");
let code = report(argv);
println!("exit {code}\n");
}
}
The help, the version, and many files
--help, -h and no arguments print HELP, a 27-line string
constant, and exit 0. The TypeScript CLI has the same constant (the
website’s CLI reference is generated from it). The Rust copy differs
only where the port does: two languages, not three, since TypeScript
input is rejected; profile “needs node” (chapter 186); --jobs runs
in parallel; and there is no --parser.
--version prints viscosity 0.0.1 from both. Rust bakes the number
in when it compiles: env!("CARGO_PKG_VERSION") is the workspace
version from Cargo.toml. The TS CLI reads package.json when it
runs. Both checks look only at the first word, so
viscosity explain a.js --help is “unknown option —help” (followed by
the help anyway).
Given several files, explain prints each report under a
== a.js == header, in your order, however many threads compiled them
(chapter 195). build needs -o <dir> and writes each file there
under its own name. A file that fails is reported as
viscosity: bad.js: Unexpected token (1:6) while the others
still compile: that is Ok(false), status 1. A single file’s error
ends the run.
What clap would change
clap is the “Command Line Argument Parser for Rust”, declarative or procedural[4]. Here is part of the CLI in clap’s declarative style. It is design, not code from the repo; I built it against clap 4.6.7 to see what it does.
// Design: not in the repository.
#[derive(Parser)]
#[command(version)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Show every decision and why
Explain {
files: Vec<String>,
#[arg(long)]
frames: bool,
#[arg(long, default_value_t = 64.0)]
min_size: f64,
},
}
Shorter, and clap writes the help itself. But it is a different CLI.
On --bogus it printed error: unexpected argument '--bogus' found,
not viscosity: unknown option --bogus, and exited with status 2,
clap’s code for usage errors, as GNU ls and Python’s argparse
use[4]. It refused --min-size 0x10, which both
real CLIs read as 16 because they follow JavaScript’s Number() (the
Rust one has js_to_number for that). It accepted --min-size inf,
which both refuse.
The repository doesn’t say why the port skipped clap. The code points
one way: the port promises to behave like the TypeScript CLI, and
multi_file.rs runs both CLIs and compares their output, and, for bad
--min-size values, their messages and exit statuses. With clap, those
would be clap’s to choose. Size is a weaker reason: clap’s derive build
added 17 crates, and cargo tree already lists 84 outside the
workspace under the CLI.
A CLI’s messages and exit statuses are part of its public API: scripts
depend on them (chapter 198). One rough edge I found: main collected
std::env::args(), so a file name that isn’t valid UTF-8 panicked
before parsing, with exit status 101, as the standard library
documents[2]. It now collects args_os(),
and such a file compiles (tests/os_args.rs). The TS CLI turns the bad
byte into U+FFFD and then can’t open the file.
Your turn
Write parse for a small part of the CLI: files, -o, --frames and
--mode. The messages are given; your job is the walk, and deciding
what each word is.
Write parse, which turns the words after the command into Args. A word without a leading - is a file. -o and --out take the next word as out; --frames sets frames; --mode takes auto or opt-in, and anything else is usage(BAD_MODE). A flag whose value is missing is needs(flag), and any other word starting with - is unknown(word). Stop at the first error.
#[derive(Debug, PartialEq, Default)]
enum Mode {
#[default]
Auto,
OptIn,
}
#[derive(Debug, PartialEq)]
enum CliError {
Usage(String),
Other(String),
}
#[derive(Debug, PartialEq, Default)]
struct Args {
files: Vec<String>,
out: String,
frames: bool,
mode: Mode,
}
// The CLI's own messages, as usage
// errors (printed with the help).
fn usage(m: &str) -> CliError {
CliError::Usage(m.to_string())
}
fn needs(w: &str) -> CliError {
usage(&format!(
"{w} needs a value"
))
}
fn unknown(w: &str) -> CliError {
usage(&format!(
"unknown option {w}"
))
}
const BAD_MODE: &str =
"--mode must be auto or opt-in";
// `argv`: the words after the command.
fn parse(
argv: &[&str],
) -> Result<Args, CliError> {
todo!()
}What you learned
- A CLI gets its arguments as strings, writes results to stdout and messages to stderr, and ends with an exit status: 0 for success, 1 for every failure in Viscosity.
- Argument parsing in the Rust CLI is a
whileloop and amatch: flags set booleans, options take the next word through anextclosure, and other words are files. CliErrorseparates usage errors, which print the help, from everything else, which doesn’t.- The help is one constant in each CLI; the versions differ only where the ports do.
- clap would be shorter, and would change messages, number parsing and the exit status (2), all of which parity holds fixed.
Check your understanding
0/5- 1.
You run
viscosity build app.js > out.jsandapp.jshas a syntax error. Where does the error message go? - 2.
Which command line makes the Rust CLI print its help text after the error?
- 3.
viscosity build a.js b.js -o out/succeeds fora.js, andb.jshas a syntax error. What is the result? - 4.
Where does
viscosity --versionget0.0.1in the Rust CLI? - 5.
A clap version of the CLI, built for this chapter, exited with status 2 on an unknown option. Why does that matter for Viscosity?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho. The Rust Programming Language, chapter 12: An I/O Project: Building a Command Line Program. The Rust Programming Language; source src/ch12-*.md in github.com/rust-lang/book. doc.rust-lang.org/book/ch12-00-an-io-project.html (checked 2026-10-31)
- [2] The Rust Project Developers. Function std::env::args (Rust standard library documentation). Rust standard library API documentation; source in library/std/src/env.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/env/fn.args.html (checked 2026-10-31)
- [3] The Rust Project Developers. Struct std::process::ExitCode (Rust standard library documentation). Rust standard library API documentation; source in library/std/src/process.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/process/struct.ExitCode.html (checked 2026-10-31)
- [4] The clap contributors. clap: README, and the exit codes in clap_builder (src/util/mod.rs), version 4.6.7. github.com/clap-rs/clap. github.com/clap-rs/clap (checked 2026-10-31)