Colour, tables, and readable output
This step finishes: the shape of hit’s output. It fails in its own words now, but everything it prints on success is flat — 200 OK and 500 Internal Server Error look exactly the same, and eleven response headers scroll past as raw name: value lines. This step colours the status by class and puts the headers in a table. It also adds the guard rail most colour tutorials skip: the colour has to disappear when stdout is not a terminal, or hit get ... | jq starts piping escape codes into your JSON.
You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch05 in the examples repository:
git clone https://github.com/hackingwithrust/examples.gitcd examples/cli-toolgit checkout cli-ch05Add the two crates
Section titled “Add the two crates”cargo add owo-colors --features supports-colorscargo add comfy-table[dependencies]clap = { version = "4", features = ["derive"] }comfy-table = "8.0.1"owo-colors = { version = "4.4.0", features = ["supports-colors"] }reqwest = { version = "0.13", features = ["blocking"] }thiserror = "2"If your Cargo.toml still lists anyhow, delete that line — nothing has used it since the error type moved in last step.
Each crate has one job. owo-colors wraps text in ANSI escape codes, the byte sequences a terminal turns into colour. comfy-table draws bordered tables that size themselves to their content. The supports-colors feature on owo-colors is what earns the extra keystrokes in the command above: it pulls in the supports-color crate, which answers one question — does this output stream actually want colour?
Why not just .green()?
Section titled “Why not just .green()?”owo-colors’s everyday methods paint unconditionally. Colour the status line the obvious way:
use owo_colors::OwoColorize;
fn main() { // paints whether the terminal wants it or not println!("{}", "200 OK".green());}On a terminal that is 200 OK in green. Piped into anything else it is the same bytes, and now those bytes are noise in the middle of someone’s data. cat -v makes them visible — it prints escape codes as ^[:
cargo run -q -- get https://example.org | head -1 | cat -v^[[32m200 OK^[[39mThat is a real run of hit with the status line coloured by plain .green(). \x1b[32m is “foreground green” and \x1b[39m is “back to the default foreground”; grep, jq and awk do not interpret either, they count them. A script doing hit get ... | grep 404 fails to match a line wrapped in bytes it cannot see.
Ask before painting
Section titled “Ask before painting”The supports-colors feature adds if_supports_color, which applies your colouring only when the target stream will actually render it. Here is the whole colour decision for hit, as a function of its own:
use owo_colors::{OwoColorize, Stream};use reqwest::StatusCode;
fn main() { for status in [ StatusCode::OK, StatusCode::MOVED_PERMANENTLY, StatusCode::NOT_FOUND, StatusCode::INTERNAL_SERVER_ERROR, ] { println!("{}", paint_status(status)); }}
fn paint_status(status: reqwest::StatusCode) -> String { let text = status.to_string(); if status.is_success() { text.if_supports_color(Stream::Stdout, |t| t.green()).to_string() } else if status.is_redirection() { text.if_supports_color(Stream::Stdout, |t| t.cyan()).to_string() } else if status.is_client_error() { text.if_supports_color(Stream::Stdout, |t| t.yellow()).to_string() } else if status.is_server_error() { text.if_supports_color(Stream::Stdout, |t| t.red()).to_string() } else { text }}200 OK301 Moved Permanently404 Not Found500 Internal Server ErrorOn a terminal those four lines come out green, cyan, yellow and red. Piped, they come out exactly as shown — plain, because Stream::Stdout is a pipe in that run and if_supports_color skipped the painting. That is the behaviour you want, and it is worth proving both ways. supports-color has a forcing switch for exactly that:
FORCE_COLOR=1 cargo run -q -- get https://example.org | head -1 | cat -v^[[32m200 OK^[[39mSame output stream, same bytes — the only difference is the environment variable. supports-color also honours NO_COLOR, so a user who sets it gets plain text even on a full-colour terminal, with no code on your side.
The .to_string() after each if_supports_color call is not decoration: the method returns a display type, not a String, and four different display types cannot share one return position. Converting each branch to String lets the if/else chain return one type.
The headers as a table
Section titled “The headers as a table”comfy-table’s job is the eleven name: value lines. The table builds like this:
use comfy_table::Table;use comfy_table::presets::UTF8_FULL;
fn main() { let mut table = Table::new(); table.load_style(UTF8_FULL); table.set_header(vec!["Header", "Value"]); table.add_row(vec!["content-type", "text/html; charset=utf-8"]); table.add_row(vec!["server", "cloudflare"]); println!("{table}");}┌──────────────┬──────────────────────────┐│ Header ┆ Value │╞══════════════╪══════════════════════════╡│ content-type ┆ text/html; charset=utf-8 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ server ┆ cloudflare │└──────────────┴──────────────────────────┘If you have read an older tutorial, you may have reached for load_preset instead of load_style — and hit this:
use comfy_table::Table;use comfy_table::presets::UTF8_FULL;
fn main() { let mut table = Table::new(); table.load_preset(UTF8_FULL);}error[E0599]: no method named `load_preset` found for struct `Table` in the current scopeVersion 8 turned the preset strings into TableStyle values and renamed the method; load_style(UTF8_FULL) is the current spelling. Any tutorial showing load_preset predates it.
Two more defaults matter here. Without set_content_arrangement(ContentArrangement::Dynamic), comfy-table never wraps cell content — long header values print in full, which is what you want from a debugging tool. And the table contains no colour codes, so it pipes cleanly no matter what; the only thing that changes between terminal and pipe is the status line.
The whole main.rs
Section titled “The whole main.rs”Only print_response and the new paint_status change; everything from the last three steps stays:
use std::error::Error;use std::process::ExitCode;use std::time::Duration;
use clap::{Parser, Subcommand};use comfy_table::presets::UTF8_FULL;use comfy_table::Table;use owo_colors::{OwoColorize, Stream};use reqwest::blocking::{Client, RequestBuilder, Response};use thiserror::Error;
#[derive(Parser)]#[command(name = "hit", version, about = "A tiny command-line HTTP client")]struct Cli { /// Print what we are about to do #[arg(short, long, global = true)] verbose: bool,
#[command(subcommand)] command: Command,}
#[derive(Subcommand)]enum Command { /// Send a GET request Get { /// The URL to request url: String,
/// Send this header, for example -H "Accept: application/json" #[arg(short = 'H', long = "header")] headers: Vec<String>, },
/// Send a POST request with a body Post { /// The URL to request url: String,
/// Request body #[arg(short, long)] body: Option<String>, },}
#[derive(Debug, Error)]enum HitError { #[error("{url} did not answer within 10 seconds")] Timeout { url: String, #[source] source: reqwest::Error, },
#[error("could not reach {url}")] Connect { url: String, #[source] source: reqwest::Error, },
#[error("invalid --header {header:?}: expected \"Name: value\"")] InvalidHeader { header: String },
#[error(transparent)] Request(#[from] reqwest::Error),}
fn main() -> ExitCode { let cli = Cli::parse();
match run(cli) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("hit: {e}"); let mut source = e.source(); while let Some(err) = source { eprintln!(" because: {err}"); source = err.source(); } ExitCode::FAILURE } }}
fn run(cli: Cli) -> Result<(), HitError> { let client = Client::builder() .timeout(Duration::from_secs(10)) .build()?;
match cli.command { Command::Get { url, headers } => { if cli.verbose { eprintln!("GET {url}"); for header in &headers { eprintln!(" header: {header}"); } }
let mut request = client.get(&url); for header in &headers { let (name, value) = header .split_once(':') .ok_or_else(|| HitError::InvalidHeader { header: header.clone() })?; request = request.header(name.trim(), value.trim()); }
let response = send(request, &url)?; print_response(response)?; }
Command::Post { url, body } => { if cli.verbose { eprintln!("POST {url}"); }
let mut request = client.post(&url); if let Some(body) = body { request = request.body(body); }
let response = send(request, &url)?; print_response(response)?; } }
Ok(())}
fn send(request: RequestBuilder, url: &str) -> Result<Response, HitError> { request.send().map_err(|error| match error { e if e.is_timeout() => HitError::Timeout { url: url.to_string(), source: e, }, e if e.is_connect() => HitError::Connect { url: url.to_string(), source: e, }, e => HitError::Request(e), })}
fn print_response(response: Response) -> Result<(), HitError> { println!("{}", paint_status(response.status()));
let mut table = Table::new(); table.load_style(UTF8_FULL); table.set_header(vec!["Header", "Value"]); for (name, value) in response.headers() { table.add_row(vec![name.as_str(), value.to_str().unwrap_or("<binary>")]); } println!("{table}");
println!(); let body = response.text()?; println!("{body}"); Ok(())}
fn paint_status(status: reqwest::StatusCode) -> String { let text = status.to_string(); if status.is_success() { text.if_supports_color(Stream::Stdout, |t| t.green()).to_string() } else if status.is_redirection() { text.if_supports_color(Stream::Stdout, |t| t.cyan()).to_string() } else if status.is_client_error() { text.if_supports_color(Stream::Stdout, |t| t.yellow()).to_string() } else if status.is_server_error() { text.if_supports_color(Stream::Stdout, |t| t.red()).to_string() } else { text }}The order in print_response matters: the table reads response.headers(), which borrows the response, so the table has to be built and printed before response.text() consumes it.
Run it:
cargo run -- get https://example.org200 OK┌─────────────────┬───────────────────────────────┐│ Header ┆ Value │╞═════════════════╪═══════════════════════════════╡│ date ┆ Fri, 16 Oct 2026 17:06:06 GMT │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ content-type ┆ text/html; charset=utf-8 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ server ┆ cloudflare │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ last-modified ┆ Tue, 13 Oct 2026 22:05:48 GMT │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ allow ┆ GET, HEAD │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ accept-ranges ┆ bytes │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ age ┆ 1561 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ cache-control ┆ max-age=14400 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ cf-cache-status ┆ HIT │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ cf-ray ┆ a4b8a6f63f873603-FRA │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ alt-svc ┆ h3=":443"; ma=86400 │└─────────────────┴───────────────────────────────┘
<!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=viewport content="width=device-width,initial-scale=1"><title>Example Domain</title><style>html{color-scheme:light dark;background:light-dark(#eee,#222)}body{font:16px/1.6 system-ui,sans-serif;max-width:26em;margin:auto;padding:25vh 2em 2em;text-align:center}</style></head><body><p>This domain is for use in documentation examples without needing permission. This is not a service; avoid relying on it for testing and monitoring purposes.</p><script src=/s.js></script></body></html>On a terminal the first line is green. Your own run will differ wherever the server answered differently — date, last-modified, age and cf-ray change between requests; the border widths move with the widest value in each column.
A miss reads differently at a glance:
cargo run -- get https://httpbin.org/status/404404 Not Found┌──────────────────────────────────┬───────────────────────────────┐│ Header ┆ Value │╞══════════════════════════════════╪═══════════════════════════════╡│ date ┆ Fri, 16 Oct 2026 17:09:33 GMT │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ content-type ┆ text/html; charset=utf-8 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ content-length ┆ 0 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ server ┆ gunicorn/19.9.0 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ access-control-allow-origin ┆ * │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ access-control-allow-credentials ┆ true │└──────────────────────────────────┴───────────────────────────────┘Two blank lines follow the table — the separator and the empty body, printed as an empty println!. On a terminal the 404 Not Found is yellow. The exit code is still 0: hit reports what came back, and deciding that a 404 counts as failure is a job for whoever pipes it.
Where the code lives
Section titled “Where the code lives”This step’s end state is tagged
cli-ch06 in the
examples repository — the project exactly as this
article leaves it:
git clone https://github.com/hackingwithrust/examples.gitcd examples/cli-toolgit checkout cli-ch06If your table borders or colours differ from the article’s, checking out the tag and diffing it against your own tree shows which line drifted.
hit now answers at a glance: green means done, yellow means you asked for something that is not
there, red means the server fell over. Next it learns to wait out loud — a spinner that appears
only when a request is actually slow. send() is the only place in the program that can take
seconds, so that is where the spinner goes.