Skip to content

Step One: A CLI That Speaks ​

A CLI that "speaks" does three things: --help introduces itself, error messages explain themselves, and the exit code tells scripts what happened. This chapter reads two jackdaw files: cli.rs defines the command line, main.rs wires it up. By the end you can answer:

  • You type jackdaw serve --port 3426. Which steps turn that string into a value in the code, and who decides what runs?
  • When the config is broken, why does jackdaw check exit with 2 instead of 1? Where in the source does that contract live?
  • Given a diff from an AI that looks like harmless cleanup, in what order do you read it so you catch the behavior change before you run the tests?

One enum defines the whole command line ​

Without clap, you parse argv yourself: see --config, grab the next word; see --port, convert a string to a number yourself; bad input means assembling your own usage message. Every CLI rewrites that code, and the help text drifts away from real behavior. clap — the command-line parsing crate from Chapter 9's ecosystem map — inverts the deal: you declare the data, it generates the parser.

jackdaw's entire command line is one enum; each variant is a subcommand:

rust
/// jackdaw — a tiny OpenAI-compatible LLM gateway with routing groups.
#[derive(Debug, Parser)]
#[command(name = "jackdaw", version, about)]
pub enum Cli {
    /// Start the gateway HTTP server.
    Serve {
        /// Config file; default lookup: ./jackdaw.toml then
        /// ~/.config/jackdaw/jackdaw.toml
        #[arg(long, value_name = "PATH")]
        config: Option<PathBuf>,
        /// Override gateway.port from the config
        #[arg(long, value_name = "PORT")]
        port: Option<u16>,
    },
    // ...
    /// Send one prompt through the same routing pipeline as the gateway
    /// (smoke test).
    Ask {
        /// Model reference: <provider>/<model-id> or group/<id>
        model_ref: String,
        /// The prompt; words are joined with spaces
        #[arg(required = true)]
        prompt: Vec<String>,
        /// Config file; default lookup as for `serve`
        #[arg(long, value_name = "PATH")]
        config: Option<PathBuf>,
        /// Wait for the whole answer and print it once (default: print as
        /// it streams)
        #[arg(long)]
        no_stream: bool,
    },
}

How to read it: the /// comment on a variant is the line jackdaw --help prints for that subcommand; comments on fields are the argument help. The module comment at the top of cli.rs turns that into a design principle: help text sits next to the definition it describes, so the two cannot drift. Now the fields. config: Option<PathBuf> (PathBuf is the standard library's path type) with #[arg(long)] is the optional --config PATH — Option means "you may leave this out". port: Option<u16> works the same way, and clap converts the string to a u16 for you, rejecting anything it can't. In Ask, model_ref carries no attribute, so it's a positional argument. prompt: Vec<String> carries required = true: at least one word, and every word you pass lands in the Vec. no_stream: bool is a flag — present means true. The three variants elided above (Models, Groups, Check) repeat the same shape. #[derive(Parser)] is a derive macro: the compiler writes impl Parser for Cli for you, and both the parsing and the help-text assembly come out of that generated code.

Command dispatch: main.rs is just wiring ​

clap hands back a Cli value with the data filled in; now jackdaw decides what to run. Here is run:

rust
async fn run(cli: Cli) -> anyhow::Result<()> {
    match cli {
        Cli::Serve { config, port } => {
            let config = config::load(config.as_deref())?;
            jackdaw::gateway::serve(config, port).await?;
        }
        // ...
        Cli::Ask {
            model_ref,
            prompt,
            config,
            no_stream,
        } => {
            let config = config::load(config.as_deref())?;
            jackdaw::ask::run(config, &model_ref, &prompt.join(" "), !no_stream).await?;
        }
    }
    Ok(())
}

Read from match cli: a pattern match over the enum, one arm per subcommand. The config and port you destructure in the braces are the same fields from the previous section — clap parsed them and passes them in. Every arm follows one recipe: load the config with ? (the ? operator; as_deref() just turns Option<PathBuf> into Option<&Path> so load can take it), then call into the library — the jackdaw:: prefix — for the actual work. The Ask arm does two small extra things: prompt.join(" ") stitches the words back into one string, and !no_stream flips the flag on the way in — the user-facing switch is named in the negative (--no-stream), the library function wants the positive. The comment at the top of main.rs states the division of labor in one sentence: everything reusable lives in the library, and this file only wires the binary edge (the main.rs vs lib.rs split is Chapter 8 material).

anyhow at the binary edge: error chains and exit codes ​

Any subcommand can fail: a missing config file, a port already taken. run's return type, anyhow::Result<()> — a Result whose error type is fixed to anyhow::Error — lets every ? bubble the error straight up to main, no forwarding by hand at each layer. anyhow's Error works like a universal envelope: it holds any error, and you can stack causes on top. The ending plays out in main:

rust
#[tokio::main]
async fn main() {
    init_tracing();

    if let Err(error) = run(Cli::parse()).await {
        // `{:#}` prints the whole anyhow chain, one cause per line.
        eprintln!("jackdaw: {error:#}");
        std::process::exit(exit_code(&error));
    }
}

/// 0 success, 2 config/env validation failure, 1 any other runtime error.
/// A config problem is anywhere in the error chain, not only at the root.
fn exit_code(error: &anyhow::Error) -> i32 {
    let is_config_problem = error
        .chain()
        .any(|cause| cause.downcast_ref::<ConfigError>().is_some());
    if is_config_problem { 2 } else { 1 }
}

Read it: Cli::parse() builds the Cli value from the process arguments and hands it to run. A parse failure — ask with no prompt, say — never reaches this line: clap itself prints a usage error to stderr and exits nonzero. An error that does get out goes to eprintln! with {error:#}, which prints the whole chain of causes, one per line, to stderr; then std::process::exit applies exit_code: 0 for success, 2 for a config or environment variable validation failure, 1 for any other runtime error. That contract is written for shell scripts and CI, and its documentation lives in the comment on exit_code.

.chain().any(...) deserves a slow read. It walks the error chain from head to tail, and if any layer is a ConfigError, returns 2. Why not check only the outermost error? The second comment line answers directly: a config problem can sit anywhere in the chain, not only at the root — library code can wrap a ConfigError inside another error before passing it up, and a root-only check misses that. #[tokio::main] and the Tokio runtime are old friends from Chapter 7; init_tracing() sets up logging — skip past it on a first read. ConfigError itself — how it's defined, and how thiserror and anyhow split the work — is Chapter 11 material.

In practice: reading a diff pi generated ​

The scene: you ask pi — the coding agent CLI from Chapter 2 — to "simplify the boolean logic in exit_code in main.rs", and it replies with a diff. The diff compiles. It even reads more idiomatically than the original. Your job is to understand it, not to trust it:

diff
 fn exit_code(error: &anyhow::Error) -> i32 {
-    let is_config_problem = error
-        .chain()
-        .any(|cause| cause.downcast_ref::<ConfigError>().is_some());
-    if is_config_problem { 2 } else { 1 }
+    match error.downcast_ref::<ConfigError>() {
+        Some(_) => 2,
+        None => 1,
+    }
 }

Read the - and + lines as pairs. The - lines first: what got deleted walks the error chain layer by layer looking for a ConfigError, then returns 2 or 1 depending on whether it found one. The + lines next: the replacement, error.downcast_ref::<ConfigError>(), checks only the outermost layer — .chain() is gone. Now the key question: did the behavior change? It did. When a ConfigError sits wrapped mid-chain, the old code returns 2 and the new code returns 1. The dangerous part of a diff is never the code that got deleted; it's the semantics that didn't get deleted along with it.

Then arbitrate. cargo check and cargo clippy both wave this through: they answer "does it compile" and "is it idiomatic", not "does it behave like before". cargo test (Chapter 15) is worth a run, but the two tests in cli.rs both watch argument parsing — which is exactly the moment Chapter 3 calls "must understand this yourself". Pick one of two moves: reject the diff, or have pi add a test — build an error with a ConfigError mid-chain, assert the exit code is still 2 — and make the simplification safe.

Summary ​

The command line is one enum: variants are subcommands, fields are arguments, doc comments are the --help text, and a derive macro makes clap generate the parser. main.rs is just wiring — Cli::parse() builds the value, match dispatches, errors bubble to one place, the chain lands on stderr, and 0/1/2 becomes the contract scripts read. Next chapter: where the "config" in that contract comes from — how jackdaw.toml becomes a typed Config.

Self-Check ​

  1. Take a multi-subcommand CLI you know (git or docker) and map it onto Cli: flags (docker's --rm), arguments that take a value (--config PATH), positional arguments (the image name at the end of docker run) — which kind of enum field does each correspond to?
  2. Predict: run jackdaw ask group/coder (no prompt), then jackdaw check --config ./no-such.toml. What happens each time, where does the output go, and what exit code comes back?
  3. Why does exit_code walk the whole error chain rather than check only the outermost layer? Describe a scenario where a root-only check gets the answer wrong.
Answers
  1. A flag is no_stream: bool (present = true). An argument that takes a value is config: Option<PathBuf> (optional; absent = None). A positional argument is a field with no attribute, like model_ref: String; to take many values, use Vec<String>, like prompt.
  2. First: clap finds the required = true prompt empty, prints a missing-argument usage error to stderr, and exits nonzero; run is never called. Second: config::load can't read the file, the error is by contract a ConfigError, ? bubbles it to main, {error:#} prints the whole chain to stderr, and exit_code finds it in the chain: exit code 2.
  3. Errors get wrapped on the way up, and a ConfigError can sink to the middle of the chain. A root-only check calls it an ordinary error (exit 1), and scripts can no longer tell "fix the config and this works" from "the program is actually broken".