Skip to content
Select theme

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:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch05
Terminal window
cargo add owo-colors --features supports-colors
cargo add comfy-table
Cargo.toml
[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?

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 ^[:

Terminal window
cargo run -q -- get https://example.org | head -1 | cat -v
^[[32m200 OK^[[39m

That 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.

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 OK
301 Moved Permanently
404 Not Found
500 Internal Server Error

On 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:

Terminal window
FORCE_COLOR=1 cargo run -q -- get https://example.org | head -1 | cat -v
^[[32m200 OK^[[39m

Same 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.

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 scope

Version 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.

Only print_response and the new paint_status change; everything from the last three steps stays:

src/main.rs
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:

Terminal window
cargo run -- get https://example.org
200 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:

Terminal window
cargo run -- get https://httpbin.org/status/404
404 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.

This step’s end state is tagged cli-ch06 in the examples repository — the project exactly as this article leaves it:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch06

If 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.

← Back to the track overview