CLI Configuration Skill
Patterns and best practices for managing configuration in command-line applications.
Configuration Precedence
The standard precedence order (lowest to highest priority):
-
Compiled defaults - Hard-coded sensible defaults
-
System config - /etc/myapp/config.toml
-
User config - ~/.config/myapp/config.toml
-
Project config - ./myapp.toml or ./.myapp.toml
-
Environment variables - MYAPP_KEY=value
-
CLI arguments - --key value (highest priority)
use config::{Config as ConfigBuilder, Environment, File};
pub fn load_config(cli: &Cli) -> Result<Config> { let mut builder = ConfigBuilder::builder() // 1. Defaults .set_default("port", 8080)? .set_default("host", "localhost")? .set_default("log_level", "info")?;
// 2. System config (if exists)
builder = builder
.add_source(File::with_name("/etc/myapp/config").required(false));
// 3. User config (if exists)
if let Some(config_dir) = dirs::config_dir() {
builder = builder.add_source(
File::from(config_dir.join("myapp/config.toml")).required(false)
);
}
// 4. Project config (if exists)
builder = builder
.add_source(File::with_name("myapp").required(false))
.add_source(File::with_name(".myapp").required(false));
// 5. CLI-specified config (if provided)
if let Some(config_path) = &cli.config {
builder = builder.add_source(File::from(config_path.as_ref()));
}
// 6. Environment variables
builder = builder.add_source(
Environment::with_prefix("MYAPP")
.separator("_")
.try_parsing(true)
);
// 7. CLI arguments (highest priority)
if let Some(port) = cli.port {
builder = builder.set_override("port", port)?;
}
Ok(builder.build()?.try_deserialize()?)
}
Config File Formats
TOML (Recommended)
Clear, human-readable, good error messages.
config.toml
[general] port = 8080 host = "localhost" log_level = "info"
[database] url = "postgresql://localhost/mydb" pool_size = 10
[features] caching = true metrics = false
[[servers]] name = "primary" address = "192.168.1.1"
[[servers]] name = "backup" address = "192.168.1.2"
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)] struct Config { general: General, database: Database, features: Features, servers: Vec<Server>, }
#[derive(Debug, Deserialize, Serialize)] struct General { port: u16, host: String, log_level: String, }
YAML (Alternative)
More concise, supports comments, complex structures.
config.yaml
general: port: 8080 host: localhost log_level: info
database: url: postgresql://localhost/mydb pool_size: 10
features: caching: true metrics: false
servers:
- name: primary address: 192.168.1.1
- name: backup address: 192.168.1.2
JSON (Machine-Readable)
Good for programmatic generation, less human-friendly.
{ "general": { "port": 8080, "host": "localhost", "log_level": "info" }, "database": { "url": "postgresql://localhost/mydb", "pool_size": 10 } }
XDG Base Directory Support
Follow the XDG Base Directory specification for cross-platform compatibility.
use directories::ProjectDirs;
pub struct AppPaths { pub config_dir: PathBuf, pub data_dir: PathBuf, pub cache_dir: PathBuf, pub state_dir: PathBuf, }
impl AppPaths { pub fn new(app_name: &str) -> Result<Self> { let proj_dirs = ProjectDirs::from("com", "example", app_name) .ok_or_else(|| anyhow!("Could not determine project directories"))?;
Ok(Self {
config_dir: proj_dirs.config_dir().to_path_buf(),
data_dir: proj_dirs.data_dir().to_path_buf(),
cache_dir: proj_dirs.cache_dir().to_path_buf(),
state_dir: proj_dirs.state_dir()
.unwrap_or_else(|| proj_dirs.data_dir())
.to_path_buf(),
})
}
pub fn config_file(&self) -> PathBuf {
self.config_dir.join("config.toml")
}
pub fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.config_dir)?;
fs::create_dir_all(&self.data_dir)?;
fs::create_dir_all(&self.cache_dir)?;
fs::create_dir_all(&self.state_dir)?;
Ok(())
}
}
Directory locations by platform:
Platform Config Data Cache
Linux ~/.config/myapp ~/.local/share/myapp ~/.cache/myapp
macOS ~/Library/Application Support/myapp ~/Library/Application Support/myapp ~/Library/Caches/myapp
Windows %APPDATA%\example\myapp %APPDATA%\example\myapp %LOCALAPPDATA%\example\myapp
Environment Variable Patterns
Naming Convention
Use APPNAME_SECTION_KEY format:
MYAPP_DATABASE_URL=postgresql://localhost/db MYAPP_LOG_LEVEL=debug MYAPP_FEATURES_CACHING=true MYAPP_PORT=9000
Integration with Clap
#[derive(Parser)] struct Cli { /// Database URL (env: MYAPP_DATABASE_URL) #[arg(long, env = "MYAPP_DATABASE_URL")] database_url: Option<String>,
/// Log level (env: MYAPP_LOG_LEVEL)
#[arg(long, env = "MYAPP_LOG_LEVEL", default_value = "info")]
log_level: String,
/// Port (env: MYAPP_PORT)
#[arg(long, env = "MYAPP_PORT", default_value = "8080")]
port: u16,
}
Sensitive Data Pattern
Never put secrets in config files. Use environment variables instead.
#[derive(Debug, Deserialize)] struct Config { pub host: String, pub port: u16,
// Loaded from environment only
#[serde(skip)]
pub api_token: String,
}
impl Config { pub fn load() -> Result<Self> { let mut config: Config = /* load from file */;
// Sensitive data from env only
config.api_token = env::var("MYAPP_API_TOKEN")
.context("MYAPP_API_TOKEN environment variable required")?;
Ok(config)
}
}
Configuration Validation
Validate configuration early at load time:
#[derive(Debug, Deserialize)] struct Config { pub port: u16, pub host: String, pub workers: usize, }
impl Config { pub fn validate(&self) -> Result<()> { // Port range if !(1024..=65535).contains(&self.port) { bail!("Port must be between 1024 and 65535, got {}", self.port); }
// Workers
if self.workers == 0 {
bail!("Workers must be at least 1");
}
let max_workers = num_cpus::get() * 2;
if self.workers > max_workers {
bail!(
"Workers ({}) exceeds recommended maximum ({})",
self.workers,
max_workers
);
}
// Host validation
if self.host.is_empty() {
bail!("Host cannot be empty");
}
Ok(())
}
}
Generating Default Config
Provide a command to generate a default configuration file:
impl Config { pub fn default_config() -> Self { Self { general: General { port: 8080, host: "localhost".to_string(), log_level: "info".to_string(), }, database: Database { url: "postgresql://localhost/mydb".to_string(), pool_size: 10, }, features: Features { caching: true, metrics: false, }, } }
pub fn write_default(path: &Path) -> Result<()> {
let config = Self::default_config();
let toml = toml::to_string_pretty(&config)?;
// Add helpful comments
let content = format!(
"# Configuration file for myapp\n\
# See: https://example.com/docs/config\n\n\
{toml}"
);
fs::write(path, content)?;
Ok(())
}
}
CLI Command:
#[derive(Subcommand)] enum Commands { /// Generate a default configuration file InitConfig { /// Output path (default: ~/.config/myapp/config.toml) #[arg(short, long)] output: Option<PathBuf>, }, }
fn handle_init_config(output: Option<PathBuf>) -> Result<()> { let path = output.unwrap_or_else(|| { AppPaths::new("myapp") .unwrap() .config_file() });
if path.exists() {
bail!("Config file already exists: {}", path.display());
}
Config::write_default(&path)?;
println!("Created config file: {}", path.display());
Ok(())
}
Config Migration Pattern
Handle breaking changes in config format:
#[derive(Debug, Deserialize)] struct ConfigV2 { version: u32, #[serde(flatten)] data: ConfigData, }
impl ConfigV2 { pub fn load(path: &Path) -> Result<Self> { let content = fs::read_to_string(path)?; let mut config: ConfigV2 = toml::from_str(&content)?;
// Migrate from older versions
match config.version {
1 => {
eprintln!("Migrating config from v1 to v2...");
config = migrate_v1_to_v2(config)?;
// Optionally save migrated config
config.save(path)?;
}
2 => {}, // Current version
v => bail!("Unsupported config version: {}", v),
}
Ok(config)
}
}
Configuration Examples Command
Provide examples in help text:
#[derive(Subcommand)] enum Commands { /// Show configuration examples ConfigExamples, }
fn show_config_examples() { println!("Configuration Examples:\n");
println!("1. Basic configuration (config.toml):");
println!("{}", r#"
[general] port = 8080 host = "localhost" "#);
println!("\n2. Environment variables:");
println!(" MYAPP_PORT=9000");
println!(" MYAPP_DATABASE_URL=postgresql://localhost/db");
println!("\n3. CLI override:");
println!(" myapp --port 9000 --host 0.0.0.0");
println!("\n4. Precedence (highest to lowest):");
println!(" CLI args > Env vars > Config file > Defaults");
}
Best Practices
-
Provide sensible defaults - App should work out-of-box
-
Document precedence - Make override behavior clear
-
Validate early - Catch config errors at startup
-
Use XDG directories - Follow platform conventions
-
Support env vars - Essential for containers/CI
-
Generate defaults - Help users get started
-
Version config format - Enable migrations
-
Keep secrets out - Use env vars for sensitive data
-
Clear error messages - Help users fix config issues
-
Document all options - With examples and defaults
References
-
XDG Base Directory Specification
-
The Twelve-Factor App: Config
-
directories crate
-
config crate