How to let a CLI read a config file
Short answer: Parse the file with serde and the toml crate into a struct that implements Default, look for it under the user’s config directory — the directories crate asks the operating system, because Rust does not expand ~ — and treat a file that is not there as “no config” rather than an error. A flag on the command line still beats the file, and the file beats the defaults.
From a fresh project — cargo new greet — Cargo.toml needs four lines:
[dependencies]clap = { version = "4.6.7", features = ["derive"] }serde = { version = "1.0.229", features = ["derive"] }toml = "1.1.8"directories = "6.0.0"The exact versions depend on when you run cargo add; the two derive features are the part that matter.
The file, and the defaults behind it
Section titled “The file, and the defaults behind it”use serde::Deserialize;
#[derive(Debug, Deserialize)]#[serde(default, deny_unknown_fields)]struct Config { name: String, retries: u32,}
impl Default for Config { fn default() -> Self { Config { name: "world".into(), retries: 3, } }}
fn main() { let config: Config = toml::from_str("name = \"Grace\"\n").unwrap(); println!("{config:?}");}Config { name: "Grace", retries: 3 }#[serde(default)] on the struct means every field the file leaves out comes from Default for Config — here retries, which the file never mentions. The file only has to say what it changes. deny_unknown_fields is the other half of that policy: without it serde discards keys it does not recognise, and retrys = 5 would configure nothing at all.
Where the file lives
Section titled “Where the file lives”use std::path::PathBuf;
use directories::ProjectDirs;
fn config_path() -> Option<PathBuf> { ProjectDirs::from("", "", "greet").map(|dirs| dirs.config_dir().join("config.toml"))}
fn main() { println!("{:?}", config_path());}$ HOME=/tmp/greet-home cargo run -qSome("/tmp/greet-home/Library/Application Support/greet/config.toml")
$ env -u HOME cargo run -qSome("/Users/rango/Library/Application Support/greet/config.toml")On macOS the config directory is $HOME/Library/Application Support; on Linux it is $XDG_CONFIG_HOME when that is set and ~/.config otherwise, so the same program looks in ~/.config/greet/config.toml there. The path moves with $HOME — the first run above pointed it at a scratch directory to show that — and unsetting it does not starve the program either: directories falls back to the passwd entry, which is what the second run shows (yours will name your own home directory). from returns None only when the operating system cannot name a home directory at all, and then the program runs on defaults. The first two arguments name a vendor; left empty here, on macOS they would build a reverse-domain bundle id.
The file that is not there
Section titled “The file that is not there”use std::{fs, io, path::PathBuf};
use clap::Parser;use directories::ProjectDirs;use serde::Deserialize;
#[derive(Debug, Deserialize)]#[serde(default, deny_unknown_fields)]struct Config { name: String, retries: u32,}
impl Default for Config { fn default() -> Self { Config { name: "world".into(), retries: 3, } }}
#[derive(Parser)]struct Args { /// Config file to read instead of the default location #[arg(long)] config: Option<PathBuf>,
/// Who to greet, overriding the config file #[arg(long)] name: Option<String>,}
fn main() { let args = Args::parse(); let config = load(args.config); let name = args.name.unwrap_or(config.name); println!("Hello, {name}! Retries: {}.", config.retries);}
fn load(override_path: Option<PathBuf>) -> Config { let path = match override_path.or_else(default_path) { Some(path) => path, None => { eprintln!("no home directory known, using defaults"); return Config::default(); } }; let text = match fs::read_to_string(&path) { Ok(text) => text, Err(e) if e.kind() == io::ErrorKind::NotFound => { eprintln!("{}: no such file, using defaults", path.display()); return Config::default(); } Err(e) => { eprintln!("{}: {e}", path.display()); std::process::exit(1); } }; match toml::from_str(&text) { Ok(config) => config, Err(e) => { eprintln!("{}: {e}", path.display()); std::process::exit(1); } }}
fn default_path() -> Option<PathBuf> { ProjectDirs::from("", "", "greet").map(|dirs| dirs.config_dir().join("config.toml"))}With no file written yet, the missing one is the expected case:
$ cargo run -q/Users/rango/Library/Application Support/greet/config.toml: no such file, using defaultsHello, world! Retries: 3.The first line is stderr, the second is stdout, and on your machine the path names your home directory. read_to_string fails the same way for a missing file as for anything else; matching on io::ErrorKind::NotFound is what separates “the user never wrote one” from a real problem. A permission error, or a directory sitting at that path — which exits 1 with Is a directory (os error 21) — lands in the last arm. Matching the error’s text instead of its kind is how that distinction breaks: the text belongs to the platform, the kind is Rust’s.
--config overrides the default location. Create a file and point at it:
mkdir -p /tmp/greet-homeprintf 'name = "Grace"\nretries = 5\n' > /tmp/greet-home/config.tomlprintf 'name = "Grace"\nretrys = 5\n' > /tmp/bad.toml$ cargo run -q -- --config /tmp/greet-home/config.tomlHello, Grace! Retries: 5.
$ cargo run -q -- --config /tmp/greet-home/config.toml --name AdaHello, Ada! Retries: 5.The file overrides the built-in defaults, and the flag overrides the file — that last step is args.name.unwrap_or(config.name). One case still stops the program: a file that exists but does not parse.
$ cargo run -q -- --config /tmp/bad.toml/tmp/bad.toml: TOML parse error at line 2, column 1 |2 | retrys = 5 | ^^^^^^unknown field `retrys`, expected `name` or `retries`The line and column name the place in the file, and the exit status is 1 — a typo that silently configured nothing would be worse. The blank line after the message is the error’s own trailing newline.
- How to add a progress bar to a CLI — the previous step, where stderr versus stdout was settled
- Reading defaults from a config file — the same three policies inside a real CLI
- Back to Building a command-line tool
The next step in this chapter tests a CLI end to end, config file and all.