Skip to content
Select theme

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.

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/rust
hit post https://httpbin.org/post --body '{"user": "hit"}'
hit get https://httpbin.org/get --header "Accept: application/json" --json
hit save gh https://api.github.com/repos/rust-lang/rust
hit run gh

Nothing 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. save and run keep 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.

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.

  • Rust, installed and current. Every step is written and tested against Rust 1.99.0 with edition 2024 — the default cargo new produces 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.
  • Result and ?. 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 new and cargo run before, 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.

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.