Reading defaults from a config file
This step finishes: the hard-coded values. Since the spinner went in, hit behaves well at every speed — but everything you might want to change still lives inside main.rs. The ten-second timeout is a line of source code. A default Accept header means retyping -H "Accept: application/json" on every invocation. This step teaches hit to read ~/.hit.toml: a timeout, default headers, and a choice of output shape, parsed with serde, the toml crate, and directories.
You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch07 in the examples repository:
git clone https://github.com/hackingwithrust/examples.gitcd examples/cli-toolgit checkout cli-ch07Add the dependencies
Section titled “Add the dependencies”cargo add serde --features derivecargo add tomlcargo add directories[dependencies]clap = { version = "4", features = ["derive"] }comfy-table = "8.0.1"directories = "6.0.0"indicatif = "0.18.6"owo-colors = { version = "4.4.0", features = ["supports-colors"] }reqwest = { version = "0.13", features = ["blocking"] }serde = { version = "1.0.229", features = ["derive"] }thiserror = "2"toml = "1.1.8"Three crates, three jobs. serde converts data into structs; its derive feature writes the conversion code for you. toml speaks the format Cargo uses for its own files. directories finds your home directory, which is less trivial than it sounds — more on that below.
The file
Section titled “The file”timeout = 5
format = "plain"
[headers]Accept = "application/json"Three settings, and a policy for each:
- The file is optional. No
~/.hit.tomlmeans the same behaviour as last step: ten-second timeout, table output, no extra headers. A missing file is not an error — most people who installhitwill never write one. - Missing fields fall back to defaults. A file with only
timeout = 5keeps the default format and headers. - A wrong value fails loudly. A key that does not exist, or a
formatthat is nottableorplain, is an error with the line and column named — not a silent ignore. A typo that silently does nothing is worse than a crash.
Describing the file to serde
Section titled “Describing the file to serde”serde’s derive macro reads the struct definition and writes the TOML-to-struct code. Here is the whole configuration model, parsing a small TOML document:
use serde::Deserialize;use std::collections::BTreeMap;
#[derive(Debug, Deserialize)]#[serde(default, deny_unknown_fields)]struct Config { /// Request timeout in seconds. timeout: u64,
/// How responses are printed: "table" (default) or "plain". format: Format,
/// Headers sent with every request. headers: BTreeMap<String, String>,}
impl Default for Config { fn default() -> Self { Config { timeout: 10, format: Format::Table, headers: BTreeMap::new(), } }}
#[derive(Debug, Default, Deserialize)]#[serde(rename_all = "lowercase")]enum Format { #[default] Table, Plain,}
fn main() { let text = r#"timeout = 5
[headers]Accept = "application/json""#; let config: Config = toml::from_str(text).unwrap(); println!("{config:?}");}Config { timeout: 5, format: Table, headers: {"Accept": "application/json"} }Each attribute carries one of the policies from the last section. #[serde(default)] on the struct means every field missing from the file comes from Default for Config — the same defaults the binary had before this step existed. deny_unknown_fields is the typo protection: without it, serde discards keys it does not recognise and timeouts = 5 would configure nothing. rename_all = "lowercase" makes "plain" in the file match the Plain variant.
The Format enum is why the output setting is a setting and not a boolean. Two named shapes read better in a config file than --no-table = true, and serde validates the value for free.
headers is a BTreeMap<String, String> — a HashMap would work too, but BTreeMap keeps its keys sorted, so the Debug output above is stable and two runs of the program iterate the headers in the same order.
Finding the file
Section titled “Finding the file”The config path is ~/.hit.toml, but Rust does not expand ~ — that is shell syntax. Path::new("~/.hit.toml") is a path with a directory literally named ~ in it. The directories crate asks the operating system instead. UserDirs::new() returns an Option because even the OS can fail to name a home directory; if that happens, running with defaults is more useful than failing. The reading logic around it is an ordinary three-way match — the same shape load_config() uses in the full listing below:
use std::fs;use std::io;
fn main() { let Some(dirs) = directories::UserDirs::new() else { println!("no home directory known, using defaults"); return; }; let path = dirs.home_dir().join(".hit.toml"); match fs::read_to_string(&path) { Ok(text) => println!("loaded:\n{text}"), Err(e) if e.kind() == io::ErrorKind::NotFound => { println!("no config file, using defaults"); } Err(e) => println!("real problem: {e}"), }}no config file, using defaultsNotFound is the expected case and gets the silent default. Any other read error — a directory sitting at that path, a permission problem — is real and becomes an error. Matching on e.kind() rather than the error’s text is what makes that distinction reliable.
Bad configs fail loudly
Section titled “Bad configs fail loudly”With deny_unknown_fields and the Format enum in place, serde’s errors name the exact problem and position. Two bad files, parsed directly:
use serde::Deserialize;use std::collections::BTreeMap;
#[derive(Debug, Deserialize)]#[serde(default, deny_unknown_fields)]struct Config { timeout: u64, format: Format, headers: BTreeMap<String, String>,}
impl Default for Config { fn default() -> Self { Config { timeout: 10, format: Format::Table, headers: BTreeMap::new(), } }}
#[derive(Debug, Default, Deserialize)]#[serde(rename_all = "lowercase")]enum Format { #[default] Table, Plain,}
fn main() { for text in ["format = \"fancy\"", "timeouts = 5"] { let error = toml::from_str::<Config>(text).unwrap_err(); println!("{error}\n"); }}TOML parse error at line 1, column 10 |1 | format = "fancy" | ^^^^^^^unknown variant `fancy`, expected `table` or `plain`
TOML parse error at line 1, column 1 |1 | timeouts = 5 | ^^^^^^^^unknown field `timeouts`, expected one of `timeout`, `format`, `headers`That is the message quality you get without writing a line of validation code. All that remains is wrapping it: two new variants in HitError, one for read failures carrying the io::Error, one for parse failures carrying the toml::de::Error. The because: chain in main() — built back in the error-handling step — prints them without any changes.
Wire it into hit
Section titled “Wire it into hit”Three changes to main.rs. load_config() runs first in run() and its timeout feeds the client builder — the hard-coded Duration::from_secs(10) is gone, and the Timeout error variant now carries the configured duration, so the message shows the real limit instead of a second hard-coded 10. add_default_headers() attaches the config’s headers before the -H arguments are parsed. And print_response() takes the Format, skipping the header table for plain. The whole file:
use std::collections::BTreeMap;use std::error::Error;use std::fs;use std::io;use std::path::PathBuf;use std::process::ExitCode;use std::thread;use std::time::Duration;
use clap::{Parser, Subcommand};use comfy_table::presets::UTF8_FULL;use comfy_table::Table;use indicatif::{ProgressBar, ProgressStyle};use owo_colors::{OwoColorize, Stream};use reqwest::blocking::{Client, RequestBuilder, Response};use serde::Deserialize;use thiserror::Error;
/// Requests that answer inside this window never show a spinner.const SLOW_AFTER: Duration = Duration::from_millis(200);
#[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 {timeout:?}")] Timeout { url: String, timeout: Duration, #[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("could not read {path}: {source}")] ConfigRead { path: PathBuf, #[source] source: io::Error, },
#[error("could not parse {path}")] ConfigParse { path: PathBuf, #[source] source: toml::de::Error, },
#[error(transparent)] Request(#[from] reqwest::Error),}
#[derive(Debug, Deserialize)]#[serde(default, deny_unknown_fields)]struct Config { /// Request timeout in seconds. timeout: u64,
/// How responses are printed: "table" (default) or "plain". format: Format,
/// Headers sent with every request, unless -H adds more. headers: BTreeMap<String, String>,}
impl Default for Config { fn default() -> Self { Config { timeout: 10, format: Format::Table, headers: BTreeMap::new(), } }}
#[derive(Debug, Default, Deserialize)]#[serde(rename_all = "lowercase")]enum Format { #[default] Table, Plain,}
fn load_config() -> Result<Config, HitError> { let Some(dirs) = directories::UserDirs::new() else { return Ok(Config::default()); }; let path = dirs.home_dir().join(".hit.toml"); let text = match fs::read_to_string(&path) { Ok(text) => text, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Config::default()), Err(e) => return Err(HitError::ConfigRead { path, source: e }), }; toml::from_str(&text).map_err(|source| HitError::ConfigParse { path, source })}
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 config = load_config()?; let timeout = Duration::from_secs(config.timeout);
let client = Client::builder().timeout(timeout).build()?;
match cli.command { Command::Get { url, headers } => { if cli.verbose { eprintln!("GET {url}"); for header in &headers { eprintln!(" header: {header}"); } }
let mut request = add_default_headers(client.get(&url), &config.headers); 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, timeout)?; print_response(response, &config.format)?; }
Command::Post { url, body } => { if cli.verbose { eprintln!("POST {url}"); }
let mut request = add_default_headers(client.post(&url), &config.headers); if let Some(body) = body { request = request.body(body); }
let response = send(request, &url, timeout)?; print_response(response, &config.format)?; } }
Ok(())}
fn add_default_headers( request: RequestBuilder, headers: &BTreeMap<String, String>,) -> RequestBuilder { let mut request = request; for (name, value) in headers { request = request.header(name, value); } request}
fn send(request: RequestBuilder, url: &str, timeout: Duration) -> Result<Response, HitError> { let spinner = start_spinner(url);
let result = request.send().map_err(|error| match error { e if e.is_timeout() => HitError::Timeout { url: url.to_string(), timeout, source: e, }, e if e.is_connect() => HitError::Connect { url: url.to_string(), source: e, }, e => HitError::Request(e), });
spinner.finish_and_clear(); result}
fn start_spinner(url: &str) -> ProgressBar { let spinner = ProgressBar::new_spinner(); spinner.set_style(ProgressStyle::with_template("{spinner:.green} {msg}").unwrap());
// The request blocks this thread, so a watchdog thread decides whether // the wait is long enough to be worth showing. Nothing is drawn until // the watchdog speaks: a fast request leaves no trace on the terminal. let url = url.to_string(); let watchdog_spinner = spinner.clone(); thread::spawn(move || { thread::sleep(SLOW_AFTER); if !watchdog_spinner.is_finished() { watchdog_spinner.set_message(format!("waiting for {url}")); watchdog_spinner.enable_steady_tick(Duration::from_millis(100)); } });
spinner}
fn print_response(response: Response, format: &Format) -> Result<(), HitError> { println!("{}", paint_status(response.status()));
if let Format::Table = format { 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 config headers go on first, so anything you pass with -H travels on top of them. Repeat the same name from both places and both values are sent — reqwest appends headers, it does not replace them. With Accept = "application/json" in the file and -H "Accept: text/plain" on the command line, the server saw Accept: application/json,text/plain. That is worth knowing before it surprises you; HTTP allows repeated headers, and servers join them.
See it work
Section titled “See it work”The runs below point HOME at a scratch directory so they cannot collide with a real config on this machine. On yours the file is ~/.hit.toml and you just create it and run the command without the override. One wrinkle while you do: rustup reads HOME too, so cargo run under an overridden HOME re-downloads the toolchain into the scratch directory. Setting RUSTUP_HOME and CARGO_HOME keeps them where they belong.
A config that only adds a default header — every request now advertises Accept: application/json without a flag, and httpbin’s /headers endpoint echoes the request headers back as proof:
mkdir -p /tmp/hit-democat > /tmp/hit-demo/.hit.toml <<'EOF'[headers]Accept = "application/json"EOFHOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \ cargo run -- get https://httpbin.org/headers200 OK┌──────────────────────────────────┬───────────────────────────────┐│ Header ┆ Value │╞══════════════════════════════════╪═══════════════════════════════╡│ date ┆ Fri, 16 Oct 2026 20:24:36 GMT │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ content-type ┆ application/json │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ content-length ┆ 152 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ server ┆ gunicorn/19.9.0 │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ access-control-allow-origin ┆ * │├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤│ access-control-allow-credentials ┆ true │└──────────────────────────────────┴───────────────────────────────┘
{ "headers": { "Accept": "application/json", "Host": "httpbin.org", "X-Amzn-Trace-Id": "Root=1-6ad28804-5e2a7c7564e0e991739e5271" }}The response body’s Accept row is the config header, not a reqwest default — without the file, that request goes out with Accept: */*. Your own run differs in the date and X-Amzn-Trace-Id rows.
format = "plain" drops the header table and keeps the status line and body:
cat > /tmp/hit-demo/.hit.toml <<'EOF'format = "plain"
[headers]Accept = "application/json"EOFHOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \ cargo run -- get https://httpbin.org/headers200 OK
{ "headers": { "Accept": "application/json", "Host": "httpbin.org", "X-Amzn-Trace-Id": "Root=1-6ad2883b-220825a6349f7f356b7b0d8a" }}And a value serde cannot accept stops the program before any request is sent:
cat > /tmp/hit-demo/.hit.toml <<'EOF'format = "fancy"EOFHOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \ cargo run -- get https://example.orghit: could not parse /tmp/hit-demo/.hit.toml because: TOML parse error at line 1, column 10 |1 | format = "fancy" | ^^^^^^^unknown variant `fancy`, expected `table` or `plain`The exit code is 1, the path in the message is wherever your home directory is, and no hit: ... line from a bad config ever reaches the network. Fix the file and the same command works.
Where the code lives
Section titled “Where the code lives”This step’s end state is tagged
cli-ch08 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-ch08If your terminal shows something different from what this article describes, checking out the tag and diffing it against your own tree shows which line drifted.
hit now takes its standing orders from a file, but it still forgets everything between runs — every request gets typed out in full, every time. Next it learns hit save <name> and hit run <name>, storing requests as JSON in a data directory. That is why this step came first: the serde derive here reads TOML, and the same traits will read and write the saved requests.