name: cli-configuration description: Configuration management patterns including file formats, precedence, environment variables, and XDG directories. Use when implementing configuration systems for CLI applications.
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