Skip to content
Select theme

Saving and reusing requests

This step finishes: the retyping. Since hit learned to read a config file, it takes its standing orders from ~/.hit.toml — but it still forgets every request between runs. The endpoint you check ten times a day still gets typed out in full, headers and all, ten times a day. This step adds two subcommands: hit save <name> stores a request as JSON, and hit run <name> replays it.

You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch08 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch08
Terminal window
cargo add serde_json
Cargo.toml
[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"] }
serde_json = "1.0.154"
thiserror = "2"
toml = "1.1.8"

serde is not new — the config step added it with its derive feature, which wrote the TOML-to-struct code for Config. What is new is the other half of the job: the same derive can write Serialize, and serde_json is the serializer that turns a struct into JSON text. One derive, two directions, two formats — the toml crate reads the config file, serde_json reads and writes saved requests.

A saved request is the GET line you would have typed: a URL and any -H headers. Saving POST bodies would need an optional field and a content-type decision, and save-and-replay is mostly a GET habit, so the struct stays at two fields:

use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct SavedRequest {
url: String,
headers: Vec<String>,
}
fn main() {
let saved = SavedRequest {
url: "https://httpbin.org/headers".to_string(),
headers: vec!["Accept: application/json".to_string()],
};
let text = serde_json::to_string_pretty(&saved).unwrap();
println!("{text}");
let back: SavedRequest = serde_json::from_str(&text).unwrap();
println!("{back:?}");
}
{
"url": "https://httpbin.org/headers",
"headers": [
"Accept: application/json"
]
}
SavedRequest { url: "https://httpbin.org/headers", headers: ["Accept: application/json"] }

to_string_pretty indents the JSON so a human can read the file; from_str turns it back into the struct, and the Debug line proves the round trip. Headers stay as raw "Name: value" strings — the same shape -H takes on the command line, so the parsing code that already exists can be reused unchanged.

The config file lives at ~/.hit.toml because it is a setting the user writes by hand. Saved requests are data the tool accumulates, and data belongs in the operating system’s data directory, not in a dotfile the user has to look at. directories is already a dependency from the config step, and it can name that directory too: BaseDirs::data_dir() returns ~/Library/Application Support on macOS, ~/.local/share on Linux (or $XDG_DATA_HOME), and the roaming application-data folder on Windows. hit appends its own folder and a .json file per request, so a request saved as httpbin lands at ~/Library/Application Support/hit/httpbin.json on a Mac.

The name is part of that path, so the name gets checked before it becomes one. An empty name, a /, or a \ is rejected outright: a name like work/api would need subdirectories that save never creates, and ../something would write outside the folder. The check is two lines in request_path(), in the full listing below.

Five new variants join HitError, and they follow the two policies the config step set: expected misses get their own readable message, real failures carry the underlying error in the because: chain.

hit run nope — no file of that name — is an expected miss, so it gets NoSuchRequest and a one-line message instead of an io-error dump. LoadRead covers every other read failure; LoadParse carries the serde_json::Error, which names the line and column of the broken JSON just as the TOML parser did for bad configs. On the write side, SaveWrite wraps the io errors from creating the folder and writing the file, and InvalidName and NoDataDir cover the two ways request_path() can fail before any file is touched.

SaveEncode deserves an honest note: a struct of Strings and Vec<String> cannot actually fail to serialize — there is no value in it that JSON cannot represent. The variant exists anyway, because the alternative is an unwrap() on to_string_pretty, and the error-handling step removed those on purpose. The because: chain in main() prints every one of these without another line of code changing.

Three changes. Two new arms in the Command enum — Save takes a name, a URL, and the same -H flags Get takes; Run takes just a name. The header-parsing block moves out of the Get arm into a send_get() helper so Run can call it verbatim: saved headers go through the same Name: value split and the same InvalidHeader error as typed ones. And the SavedRequest struct plus its save/load functions sit next to load_config(). The whole file:

src/main.rs
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, Serialize};
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>,
},
/// Save a GET request to reuse later
Save {
/// Name to store the request under
name: String,
/// 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 saved request
Run {
/// The name it was saved under
name: 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("invalid name {name:?}: use a plain file name, no path separators")]
InvalidName { name: String },
#[error("could not locate a data directory")]
NoDataDir,
#[error("could not write {path}: {source}")]
SaveWrite {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not encode the request for {name:?}")]
SaveEncode {
name: String,
#[source]
source: serde_json::Error,
},
#[error("no saved request named {name:?}")]
NoSuchRequest { name: String },
#[error("could not read {path}: {source}")]
LoadRead {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not parse {path}")]
LoadParse {
path: PathBuf,
#[source]
source: serde_json::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 overrides them.
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,
}
#[derive(Debug, Serialize, Deserialize)]
struct SavedRequest {
url: String,
headers: Vec<String>,
}
fn requests_dir() -> Option<PathBuf> {
let dirs = directories::BaseDirs::new()?;
Some(dirs.data_dir().join("hit"))
}
fn request_path(name: &str) -> Result<PathBuf, HitError> {
if name.is_empty() || name.contains('/') || name.contains('\\') {
return Err(HitError::InvalidName {
name: name.to_string(),
});
}
let Some(dir) = requests_dir() else {
return Err(HitError::NoDataDir);
};
Ok(dir.join(format!("{name}.json")))
}
fn save_request(name: &str, url: &str, headers: &[String]) -> Result<PathBuf, HitError> {
let path = request_path(name)?;
let saved = SavedRequest {
url: url.to_string(),
headers: headers.to_vec(),
};
let text = serde_json::to_string_pretty(&saved).map_err(|source| HitError::SaveEncode {
name: name.to_string(),
source,
})?;
let Some(dir) = path.parent() else {
return Err(HitError::NoDataDir);
};
fs::create_dir_all(dir).map_err(|source| HitError::SaveWrite {
path: path.clone(),
source,
})?;
fs::write(&path, text).map_err(|source| HitError::SaveWrite {
path: path.clone(),
source,
})?;
Ok(path)
}
fn load_request(name: &str) -> Result<SavedRequest, HitError> {
let path = request_path(name)?;
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(HitError::NoSuchRequest {
name: name.to_string(),
});
}
Err(e) => return Err(HitError::LoadRead { path, source: e }),
};
serde_json::from_str(&text).map_err(|source| HitError::LoadParse { path, source })
}
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}");
}
}
send_get(&client, &config, timeout, url, headers)?;
}
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)?;
}
Command::Save { name, url, headers } => {
let path = save_request(&name, &url, &headers)?;
println!("saved {name} to {}", path.display());
}
Command::Run { name } => {
let saved = load_request(&name)?;
if cli.verbose {
eprintln!("GET {}", saved.url);
for header in &saved.headers {
eprintln!(" header: {header}");
}
}
send_get(&client, &config, timeout, saved.url, saved.headers)?;
}
}
Ok(())
}
fn send_get(
client: &Client,
config: &Config,
timeout: Duration,
url: String,
headers: Vec<String>,
) -> Result<(), HitError> {
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)?;
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 Save arm prints the path it wrote, so you never have to guess where the file went. Run rebuilds the request from the file and the rest of the pipeline — spinner, status colour, header table — runs exactly as it does for get.

The runs below point HOME at a scratch directory so they cannot collide with real saved requests on this machine. On yours the file lands under your own home directory and you run the commands without the override. Same wrinkle as last step: rustup reads HOME too, so RUSTUP_HOME and CARGO_HOME keep the toolchain where it belongs.

Saving a request, then looking at the file:

Terminal window
HOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \
cargo run -- save httpbin https://httpbin.org/headers -H "Accept: application/json"
saved httpbin to /tmp/hit-demo/Library/Application Support/hit/httpbin.json
Terminal window
cat "/tmp/hit-demo/Library/Application Support/hit/httpbin.json"
{
"url": "https://httpbin.org/headers",
"headers": [
"Accept: application/json"
]
}

The JSON is the struct from the last section, one field per line, readable by eye. Replaying it — the saved header travels on the request, and httpbin’s /headers endpoint echoes it back as proof:

Terminal window
HOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \
cargo run -- run httpbin
200 OK
┌──────────────────────────────────┬───────────────────────────────┐
│ Header ┆ Value │
╞══════════════════════════════════╪═══════════════════════════════╡
│ date ┆ Fri, 16 Oct 2026 21:51: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-6ad29c68-3671d38c507731a33bfc4387"
}
}

Identical output to typing hit get https://httpbin.org/headers -H "Accept: application/json" — that is the point. Your own run differs in the date and X-Amzn-Trace-Id rows.

The misses fail the way the error section promised:

Terminal window
HOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \
cargo run -- run nope
hit: no saved request named "nope"
Terminal window
printf '{"url": 42}' > "/tmp/hit-demo/Library/Application Support/hit/broken.json"
HOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \
cargo run -- run broken
hit: could not parse /tmp/hit-demo/Library/Application Support/hit/broken.json
because: invalid type: integer `42`, expected a string at line 1 column 10
Terminal window
HOME=/tmp/hit-demo RUSTUP_HOME="$HOME/.rustup" CARGO_HOME="$HOME/.cargo" \
cargo run -- save "../tricky" https://example.org
hit: invalid name "../tricky": use a plain file name, no path separators

All three exit with code 1 and never touch the network. The parse error names the exact position, same as the TOML errors did.

This step’s end state is tagged cli-ch09 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-ch09

If 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 can now remember requests, but it still sends one at a time — the blocking client holds the thread until the response arrives. Next the blocking client goes, replaced by async Rust with tokio: one saved request fires at a time no longer, several run concurrently, and the output is summarised. The SavedRequest files this step writes are exactly what that step will read.

← Back to the track overview