Viscosity
Review
Part XII · Shipping · Day 50

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.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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.

Definition

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:

Step throughParsing one command line
parse_args walking the words of a command line and filling in Args, ending in a usage errorargvbuilda.jsb.js-oout/--modefastmatch arm(not started)Argscmd""files[]out""opts.modeMode::Auto (default)
The shell splits the command line into words. std::env::args_os() hands them over as OsStrings (a file name need not be UTF-8), the program’s own name first, and main skips that: argv is these seven strings.
1/7
Definition

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:

Try itTwo kinds of error, one exit status
// 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");
    }
}
Open in Rust Playground ↗

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.

Under the hood

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.

ExerciseA small argument parser

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!()
}
Open in Rust Playground ↗

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 while loop and a match: flags set booleans, options take the next word through a next closure, and other words are files.
  • CliError separates 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. 1.

    You run viscosity build app.js > out.js and app.js has a syntax error. Where does the error message go?

  2. 2.

    Which command line makes the Rust CLI print its help text after the error?

  3. 3.

    viscosity build a.js b.js -o out/ succeeds for a.js, and b.js has a syntax error. What is the result?

  4. 4.

    Where does viscosity --version get 0.0.1 in the Rust CLI?

  5. 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. [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. [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. [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. [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)