What we're building: hit, a command-line HTTP client
Step 1 of 12, and it runs nothing. This is the map: what hit does when the track is finished,
then the eleven steps between here and there. Read it once so every later article has a place to
land — each one assumes the state the previous step left behind and adds one feature to it.
A command-line tool is the right first project in Rust. There is no framework to accept on faith
and no server to keep running; the feedback loop is cargo run, and the problems you hit — parsing
input, handling failure, printing something readable — are the ones the rest of the language is
built around.
The finished tool
Section titled “The finished tool”hit is a command-line HTTP client: the thing you reach for when opening a browser tab is more
friction than the request deserves, and curl’s flag soup is more friction still. By step 12 the
command line looks like this:
hit get https://api.github.com/repos/rust-lang/rusthit post https://httpbin.org/post --body '{"user": "hit"}'hit get https://httpbin.org/get --header "Accept: application/json" --jsonhit save gh https://api.github.com/repos/rust-lang/rusthit run ghNothing exotic. get and post are subcommands, modelled as a Rust enum, so the compiler — not a
string comparison — decides what the tool accepts. Headers repeat: every --header you pass is
sent. --json prints the body bare, so hit get ... --json | jq . does the obvious thing. save
stores a request under a short name and run replays it; the requests you type more than twice
stop being typed.
Under that surface, the track builds the parts a real tool needs and a tutorial usually skips:
- Errors worth reading. A failed request prints what went wrong and what to try next, not a panic backtrace. Step 5 builds a custom error type for that.
- Output designed for a terminal. Status codes coloured by class, headers as a table, and a spinner that appears only when the request is actually slow — a spinner on a 40 ms request is noise. Steps 6 and 7.
- Memory. A config file holds your default headers and timeout, so the commands above stay
short.
saveandrunkeep requests as JSON you can check into a repo. Steps 8 and 9. - Speed where it pays. Checking several endpoints means one connection pool and concurrent requests, not a dozen sequential round trips. Step 10 introduces async where it is actually used.
- Proof it works. Tests that exercise the whole binary without touching the network. Step 11.
- A binary you can hand over. A stripped release build, cross-compiled for other platforms. Step 12.
That is the whole surface. No plugin system, no DSL — a tool that does one job and stays out of the way.
The route
Section titled “The route”Twelve steps, published in this order. Each ends at a state the next one picks up from: a hit
that compiles, does one more thing, and does one thing less clumsily than before.
| # | Article | What lands |
|---|---|---|
| 2 | Setting up a Rust CLI project | cargo new, the layout, and the first dependency |
| 3 | Parsing command-line arguments with clap | derive, subcommands, and repeatable flags |
| 4 | Making a real HTTP request with reqwest | status, headers, body, and a timeout that works |
| 5 | Handling errors without panicking | a custom error type and messages worth reading |
| 6 | Colour, tables, and readable output | status-code colours and headers as a table |
| 7 | Showing progress for slow requests | a spinner that appears only when it should |
| 8 | Reading defaults from a config file | defaults from TOML with serde |
| 9 | Saving and reusing requests | hit save and hit run, stored as JSON |
| 10 | Making it fast: async with tokio | concurrent requests and joining the results |
| 11 | Testing a CLI without a network | assert_cmd plus a mock server |
| 12 | Shipping it: release builds and installers | release profile, stripping, multi-platform binaries |
The shape of it: steps 2–4 stand up a client that sends one real request. Steps 5–7 make its failures and its output worth looking at. Steps 8–10 give it memory — defaults, saved requests, concurrency. Steps 11 and 12 are the difference between a script you run in a terminal and a tool someone else can install.
Step 3 is already live, and it is the only step you can read ahead on: hit grows its get and
post subcommands, a repeatable --header flag, and --help output nobody wrote by hand. The
rest land as they are written.
What you need first
Section titled “What you need first”- Rust, installed and current. Every step is written and tested against Rust 1.99.0 with
edition 2024 — the default
cargo newproduces on a current toolchain. rustup.rs installs it or brings an old install up to date. - Ownership basics. You will move strings into structs and borrow them back out. The first chapters of Ownership and borrowing are enough.
Resultand?. The tool never panics on a bad response, which means errors flow through the program instead of ending it. Error handling covers the pattern.- Cargo, casually. If you have run
cargo newandcargo runbefore, you are set; otherwise the Cargo chapter is short.
No async experience required. The track avoids it until step 10, then explains it against the code you have already written.
Where the code lives
Section titled “Where the code lives”Steps that change the project commit the result to cli-tool/ in
the examples repository and tag it — cli-ch02
after the setup step, and so on. If your build misbehaves partway through, checking out that tag in
your clone puts your tree exactly where the article left it, and a diff against your own files
shows which line drifted.
Step 2 starts the project for real: cargo new hit, the four files cargo generates, and the first
dependency. From that article on, every step ends with something you can run. If you want to see
where the command line is heading, the clap article
is already on the site, and the track overview keeps the full list current.