| Crates.io | settings_loader |
| lib.rs | settings_loader |
| version | 1.0.0 |
| created_at | 2025-02-11 00:37:08.362169+00 |
| updated_at | 2026-01-06 23:50:35.755828+00 |
| description | Opinionated configuration settings load mechanism for Rust applications |
| homepage | https://github.com/dmrolfs/settings-loader-rs |
| repository | https://github.com/dmrolfs/settings-loader-rs |
| max_upload_size | |
| id | 1550833 |
| size | 1,294,340 |
A comprehensive Rust configuration management library that unifies multiple sources into type-safe, validated settings.
settings-loader wraps and extends config-rs with powerful features for modern Rust applications: bidirectional editing with comment preservation, metadata-driven introspection, multi-scope path resolution, and source provenance tracking.
Status: Production-ready at v1.0.0 with comprehensive test coverage (88%+ mutation score).
Multi-Source Composition: Seamlessly merge configuration from files, environment variables, secrets, and CLI arguments with customizable precedence rules.
Type Safety: Leverage Rust's type system and serde for compile-time guarantees—no runtime type errors.
Metadata & Introspection: Generate UIs, validate configs, and produce documentation directly from your settings structs.
Bidirectional Editing: Not just read—write back to config files while preserving comments and formatting.
Multi-Scope Support: Handle user-global, project-local, and system configurations with platform-appropriate paths.
Provenance Tracking: Debug configuration issues by knowing exactly where each value came from.
Add to your Cargo.toml:
[dependencies]
settings-loader = "1.0"
Define your settings and load them:
use serde::Deserialize;
use settings_loader::{SettingsLoader, NoOptions};
#[derive(Debug, Deserialize)]
struct AppSettings {
host: String,
port: u16,
debug: bool,
}
impl SettingsLoader for AppSettings {
type Options = NoOptions;
fn app_config_basename() -> &'static str {
"myapp" // Looks for myapp.yaml, myapp.toml, etc.
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let settings = AppSettings::load(&NoOptions)?;
println!("Server: {}:{}", settings.host, settings.port);
Ok(())
}
Create myapp.yaml in your project root:
host: "127.0.0.1"
port: 8080
debug: true
That's it! You're loading configuration in under 5 minutes.
Load configuration from any of these formats (listed in precedence order):
.yaml, .yml) - Highest precedence.toml).json).json5).hjson).ron) - Lowest precedenceThe format is automatically detected by file extension. Extension precedence applies independently for each configuration layer. For example, if a directory contains both settings.yaml and settings.json, the YAML file will be loaded. This precedence order is defined by the underlying config-rs library.
Configuration sources are merged with well-defined precedence. The default precedence (highest to lowest):
production.yaml, local.yaml)application.yaml)This default enables the 12-factor app pattern: store config in the environment, separate secrets, and maintain environment-specific overrides.
Customizable Precedence: You can establish any precedence order using LayerBuilder to define explicit configuration layers. See Configuration Source Patterns for examples including desktop/CLI application patterns (system→user→project→runtime) and containerized application patterns.
Settings are deserialized into strongly-typed Rust structs using serde. This means:
settings-loader uses Cargo features to keep dependencies minimal. Enable only what you need:
[dependencies]
settings-loader = { version = "1.0", features = ["database", "http", "multi-scope", "editor"] }
| Feature | Description | Dependencies |
|---|---|---|
metadata (default) |
Metadata, introspection, validation, and schema generation | serde_json, regex, zeroize |
database |
PostgreSQL connection settings with secrecy integration |
sqlx, secrecy, zeroize |
http |
HTTP server configuration with URL validation | url |
multi-scope |
User-global vs project-local path resolution | directories |
editor |
Bidirectional editing with comment preservation | toml_edit, parking_lot, serde_json, serde_yaml |
Default features: metadata is enabled by default, providing introspection and validation capabilities.
Minimal installation: Use default-features = false to disable all optional features:
[dependencies]
settings-loader = { version = "1.0", default-features = false }
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct MySettings {
pub server: ServerSettings,
pub database: DatabaseSettings,
}
#[derive(Debug, Deserialize)]
struct ServerSettings {
pub host: String,
pub port: u16,
pub timeout_secs: u64,
}
#[derive(Debug, Deserialize)]
struct DatabaseSettings {
pub host: String,
pub port: u16,
pub database_name: String,
pub max_connections: u32,
}
For simple cases, use NoOptions. For custom loading behavior, implement LoadingOptions:
use std::path::PathBuf;
use settings_loader::{LoadingOptions, SettingsError};
use clap::Parser;
#[derive(Parser)]
struct CliOptions {
/// Path to configuration file
#[arg(short, long)]
config: Option<PathBuf>,
/// Path to secrets file
#[arg(long)]
secrets: Option<PathBuf>,
/// Environment (local, production, etc.)
#[arg(short, long)]
env: Option<String>,
}
impl LoadingOptions for CliOptions {
type Error = SettingsError;
fn config_path(&self) -> Option<PathBuf> {
self.config.clone()
}
fn secrets_path(&self) -> Option<PathBuf> {
self.secrets.clone()
}
fn implicit_search_paths(&self) -> Vec<PathBuf> {
vec![PathBuf::from("./config"), PathBuf::from("./")]
}
}
use settings_loader::SettingsLoader;
impl SettingsLoader for MySettings {
type Options = CliOptions;
fn app_config_basename() -> &'static str {
"application" // Looks for application.yaml, application.toml, etc.
}
}
use clap::Parser;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let options = CliOptions::parse();
let settings = MySettings::load(&options)?;
println!("Connecting to database at {}:{}",
settings.database.host,
settings.database.port
);
// Use settings in your application...
Ok(())
}
This section describes how configuration sources are discovered, composed, and loaded. Understanding these mechanisms helps you structure your application's configuration effectively.
The LoadingOptions::config_path() method provides an explicit override for the primary configuration file. When set, it bypasses implicit search and loads the specified file directly. This is useful for:
--config /path/to/config.yamlRelationship with Multi-Scope: config_path() takes precedence over multi-scope path resolution. If you provide an explicit path, multi-scope discovery is skipped for the base configuration (though secrets and environment-specific files may still use multi-scope paths).
multi-scope feature)Applications often need configuration at different scopes: system-wide defaults, user preferences, and project-specific settings. The multi-scope feature provides automatic, platform-appropriate path resolution using the directories crate.
Platform-Specific Paths:
| Scope | Linux | macOS | Windows |
|---|---|---|---|
| System | /etc/<app>/ |
/Library/Application Support/<app>/ |
C:\ProgramData\<org>\<app>\ |
| UserGlobal | ~/.config/<app>/ |
~/Library/Application Support/<app>/ |
C:\Users\<user>\AppData\Roaming\<org>\<app>\ |
| ProjectLocal | ./<app>.{yaml,toml,json} |
./<app>.{yaml,toml,json} |
.\<app>.{yaml,toml,json} |
Implement MultiScopeConfig to enable automatic path resolution:
use settings_loader::{MultiScopeConfig, ConfigScope};
impl MultiScopeConfig for MySettings {
const APP_NAME: &'static str = "myapp";
const ORG_NAME: &'static str = "myorg";
}
// Automatically resolves to platform-appropriate paths
let user_config = MySettings::resolve_path(ConfigScope::UserGlobal);
let project_config = MySettings::resolve_path(ConfigScope::ProjectLocal);
Common Use Case: CLI tools that respect user preferences while allowing project-specific overrides. For example, a code formatter might have global style preferences in ~/.config/formatter/formatter.toml but allow per-project overrides in ./formatter.toml.
File Discovery: When using MultiScopeConfig, the library searches for files named <basename>.{yaml,yml,toml,json,ron,hjson,json5} in each scope's directory, where <basename> is determined by your app_config_basename() implementation.
The LayerBuilder API provides explicit control over configuration source composition and precedence. This is more powerful than relying on defaults, allowing you to define exactly which sources to load and in what order.
Available Layer Types:
with_path(path) - Load from explicit file pathwith_path_in_dir(dir, basename) - Discover file by basename in directory (searches for basename.{yaml,yml,toml,json,ron,hjson,json5})with_env_var(var_name) - Load from path specified in environment variablewith_env_search(env, dirs) - Search directories for environment-specific files (e.g., production.yaml)with_secrets(path) - Load secrets from filewith_env_vars(prefix, separator) - Load from system environment variableswith_scopes<T>(scopes) - Load from multiple configuration scopes (requires MultiScopeConfig)use settings_loader::{LayerBuilder, LoadingOptions};
impl LoadingOptions for MyOptions {
fn build_layers(&self, builder: LayerBuilder) -> LayerBuilder {
builder
.with_path_in_dir("config", "base") // Discovers config/base.{yaml,toml,json,...}
.with_path("config/production.yaml") // Environment override
.with_secrets("secrets/db.yaml") // Secrets (not in git)
.with_env_vars("APP", "__") // ENV var overrides
// Highest precedence wins
}
}
Key Insight: Layers are applied in order, with later layers overriding earlier ones. This gives you complete control over precedence.
Real-world applications have diverse configuration needs. Here are proven patterns for common scenarios.
The simplest pattern for cloud-native applications following 12-factor principles:
impl LoadingOptions for MyOptions {
fn build_layers(&self, builder: LayerBuilder) -> LayerBuilder {
builder
.with_path_in_dir("config", "application") // Base config (in git)
.with_env_vars("APP", "__") // Runtime overrides
}
}
Precedence: Environment variables > Base configuration
Use Case: Containerized applications where configuration is primarily environment-driven.
A comprehensive pattern for desktop and CLI applications that respect multiple configuration scopes:
impl MultiScopeConfig for AppSettings {
const APP_NAME: &'static str = "myapp";
const ORG_NAME: &'static str = "myorg";
}
impl LoadingOptions for AppOptions {
fn build_layers(&self, builder: LayerBuilder) -> LayerBuilder {
let mut layers = builder;
// System defaults (read-only, managed by package manager)
if let Some(path) = Self::resolve_path(ConfigScope::System) {
if path.exists() {
layers = layers.with_path(path);
}
}
// User global preferences
if let Some(path) = Self::resolve_path(ConfigScope::UserGlobal) {
if path.exists() {
layers = layers.with_path(path);
}
}
// Project-local configuration
if let Some(path) = Self::resolve_path(ConfigScope::ProjectLocal) {
if path.exists() {
layers = layers.with_path(path);
}
}
// Runtime: environment variables
layers = layers.with_env_vars("APP", "__");
// Secrets (if provided via CLI)
if let Some(secrets) = &self.secrets {
layers = layers.with_secrets(secrets);
}
layers
}
}
Precedence: Secrets > Env Vars > Project > User > System
Use Case: CLI tools, desktop applications, development tools that need flexible configuration across different contexts.
Optimized for containerized deployments where configuration comes primarily from environment and mounted secrets:
impl LoadingOptions for ServerOptions {
fn build_layers(&self, builder: LayerBuilder) -> LayerBuilder {
builder
// Baked-in defaults (in container image)
.with_path("/app/config/defaults.yaml")
// Environment-specific config (mounted volume)
.with_path("/config/production.yaml")
// Secrets (mounted from secret manager)
.with_secrets("/run/secrets/database")
// Runtime overrides (Kubernetes env vars, etc.)
.with_env_vars("APP", "__")
}
}
Precedence: Env Vars > Secrets > Mounted Config > Defaults
Use Case: Docker/Kubernetes deployments with ConfigMaps, Secrets, and environment variables.
Deployment Example:
# docker-compose.yml
services:
api:
image: myapp:latest
environment:
- APP__SERVER__PORT=8080
- APP__DATABASE__MAX_CONNECTIONS=20
volumes:
- ./config/production.yaml:/config/production.yaml:ro
secrets:
- database
Separate base configuration from environment-specific overrides:
config/
├── application.yaml # Base config (version controlled)
├── local.yaml # Local development overrides
└── production.yaml # Production overrides
secrets/
└── database.yaml # Secrets (NOT in version control)
impl LoadingOptions for MyOptions {
fn build_layers(&self, builder: LayerBuilder) -> LayerBuilder {
let mut layers = builder.with_path("config/application.yaml");
// Add environment-specific config
if let Some(env) = &self.environment {
layers = layers.with_path(format!("config/{}.yaml", env));
}
// Add secrets if available
if let Some(secrets) = &self.secrets {
layers = layers.with_secrets(secrets);
}
// Environment variables override everything
layers.with_env_vars("APP", "__")
}
}
Precedence: Env Vars > Secrets > Environment File > Base Config
editor feature)Applications often need to persist user preferences or update configuration programmatically. Naive file writing loses comments and formatting, frustrating users who maintain carefully documented configs. The editor feature solves this by providing bidirectional editing with format preservation, particularly for TOML files where comments are common.
Core Capability: The LayerEditor trait allows reading and writing individual configuration layers while preserving structure and formatting. Comment preservation is currently supported for TOML files only (using toml_edit). JSON and YAML editors use standard serde serialization, which does not preserve comments.
use settings_loader::{SettingsEditor, ConfigScope};
// Edit project-local settings (TOML comments preserved!)
let mut editor = MySettings::editor(ConfigScope::ProjectLocal, &options)?;
// Get current value
let port: u16 = editor.get("server.port")?.unwrap_or(8080);
// Update value (comments preserved!)
editor.set("server.port", 9000)?;
editor.save()?;
// Later: reload and verify
let updated = MySettings::load(&options)?;
assert_eq!(updated.server.port, 9000);
Use Cases:
metadata feature, default)Building UIs, validating configurations, and generating documentation often requires knowing what settings exist, their types, constraints, and defaults. Hardcoding this information in multiple places (code, docs, UI) creates maintenance burden and drift. The metadata feature provides a single source of truth: register metadata once, use it everywhere.
Core Capability: Register metadata for your settings and automatically generate JSON Schema, HTML documentation, example configs, and validation rules. This metadata can also drive UI generation for TUI/CLI tools.
use settings_loader::metadata::{SettingMetadata, SettingType, Constraint, Visibility};
use settings_loader::registry;
// Initialize registry
registry::init_global_registry("My App", "1.0.0");
// Register setting metadata
registry::register_setting(SettingMetadata {
key: "server.port".to_string(),
label: "Server Port".to_string(),
description: "The port the HTTP server will listen on.".to_string(),
setting_type: SettingType::Integer { min: Some(1024), max: Some(65535) },
default: Some(serde_json::json!(8080)),
constraints: vec![
Constraint::Required,
Constraint::Range { min: 1024.0, max: 65535.0 }
],
visibility: Visibility::Public,
group: Some("Server".to_string()),
});
// Export JSON Schema
MySettings::export_json_schema("schema.json")?;
// Export HTML documentation
MySettings::export_docs("docs.html")?;
// Export example config
MySettings::export_example_config("application.example.toml")?;
Use Cases:
--help text from metadataSee examples/schema_generation.rs for a complete example.
Configuration errors should be caught early with clear, actionable error messages. Waiting until runtime to discover that a port number is invalid or a required field is missing wastes time and creates poor user experience. The validation system provides declarative constraints that are checked automatically during loading.
Core Capability: Define constraints on your settings (required, range, pattern, etc.) and get automatic validation with detailed error messages that guide users to fix issues.
use settings_loader::metadata::Constraint;
// Define constraints
let constraints = vec![
Constraint::Required,
Constraint::Range { min: 1024.0, max: 65535.0 },
Constraint::Pattern {
pattern: r"^\d{1,5}$".to_string()
},
];
// Validation happens automatically when loading
let settings = MySettings::load(&options)?;
// If validation fails, you get detailed error messages:
// "Setting 'server.port' is out of range: expected 1024-65535, got 80"
Use Cases:
When debugging configuration issues in production, you need to know where each value came from. Was it the base config? An environment variable? A secrets file? A user override? Without provenance tracking, you're left guessing or manually checking multiple sources. The provenance system tracks the source of every configuration value.
Core Capability: Load settings with full provenance information, allowing you to query the source of any value for debugging, auditing, or understanding configuration precedence.
use settings_loader::SettingsLoader;
let (settings, sources) = MySettings::load_with_provenance(&options)?;
// Find out where a specific setting came from
if let Some(source) = sources.source_of("database.host") {
match source.source_type {
SourceType::File => println!("From file: {:?}", source.path),
SourceType::Environment => println!("From env var: {}", source.id),
SourceType::Default => println!("Using default value"),
_ => {}
}
}
// Get all settings from a specific scope
let user_settings = sources.all_from_scope(ConfigScope::UserGlobal);
Use Cases:
See examples/provenance_audit.rs for a complete example.
Combining environment variables with file-based configuration is a cornerstone of cloud-native applications. Environment variables provide runtime flexibility while files provide structure and documentation.
impl LoadingOptions for MyOptions {
fn build_layers(&self, builder: LayerBuilder) -> LayerBuilder {
builder
.with_path("config.yaml")
.with_env_vars("APP", "__") // APP__DATABASE__HOST overrides database.host
}
}
Environment variable naming convention:
APP (customizable)__ (double underscore)APP__DATABASE__HOST=localhost sets database.hostPrecedence: Environment variables override file-based configuration.
# Override database host via environment variable
export APP__DATABASE__HOST=prod.db.example.com
export APP__DATABASE__PORT=5432
# Run application (env vars override config files)
cargo run
Sensitive values like passwords, API keys, and certificates should never be committed to version control. The secrecy crate integration ensures secrets are handled safely and redacted in error messages.
use secrecy::{Secret, ExposeSecret};
use serde::Deserialize;
#[derive(Deserialize)]
struct DatabaseSettings {
pub host: String,
pub username: String,
#[serde(deserialize_with = "deserialize_secret")]
pub password: Secret<String>,
}
// Secrets are automatically redacted in error messages
impl std::fmt::Debug for DatabaseSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DatabaseSettings")
.field("host", &self.host)
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
// Use the secret
let connection_string = format!(
"postgres://{}:{}@{}/db",
settings.database.username,
settings.database.password.expose_secret(),
settings.database.host
);
Command-line arguments should have the highest precedence, allowing users to override any configuration for testing or one-off operations.
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(short, long)]
env: Option<String>,
/// Override database host
#[arg(long)]
db_host: Option<String>,
}
impl LoadingOptions for Cli {
fn load_overrides(&self, config: ConfigBuilder<DefaultState>)
-> Result<ConfigBuilder<DefaultState>, Self::Error>
{
let mut config = config;
// Apply CLI overrides
if let Some(host) = &self.db_host {
config = config.set_override("database.host", host.clone())?;
}
Ok(config)
}
}
Precedence: CLI arguments > environment variables > files > defaults.
The examples/ directory contains complete, runnable examples:
schema_generation.rs: Demonstrates metadata registration and exporting JSON Schema, HTML documentation, and example TOML configs.provenance_audit.rs: Shows source tracking and debugging configuration by identifying where each value originated.Run examples with:
cargo run --example schema_generation --features metadata
cargo run --example provenance_audit
settings-loader wraps and builds on config-rs, extending it with additional capabilities rather than replacing it. This means you get all the benefits of config-rs's mature multi-source merging and serde integration, plus the features below.
| Feature | config-rs | figment | settings-loader |
|---|---|---|---|
| Multi-source merging | ✅ | ✅ | ✅ (via config-rs) |
| Serde integration | ✅ | ✅ | ✅ (via config-rs) |
| Multiple formats | ✅ | ✅ | ✅ (via config-rs) |
| Bidirectional editing | ❌ | ❌ | ✅ (with comment preservation) |
| Metadata/introspection | ❌ | Limited | ✅ (full schema generation) |
| Multi-scope paths | Manual | Manual | ✅ (platform-aware via directories) |
| Provenance tracking | ❌ | ✅ | ✅ (detailed source info) |
| Opinionated patterns | ❌ | ❌ | ✅ (12-factor, multi-scope, etc.) |
Choose settings-loader if you need:
Choose config-rs directly if you only need basic multi-source loading and don't require the additional features.
Choose figment if you prefer its API style and need its specific features (like typed providers).
settings-loader is production-ready with:
multi-scope feature)editor feature)metadata feature)Possible future enhancements include configuration hot reload, remote configuration sources (etcd, Consul, AWS Parameter Store), IDE integration via LSP, configuration diffing & migration, validation UI, templates & profiles, observability, encryption at rest, testing framework, and web-based editor.
See ref/FUTURE_ENHANCEMENTS.md for detailed descriptions and history/CONSOLIDATED_ROADMAP.md for technical roadmap.
Contributions are welcome! Please:
cargo testLicensed under the MIT License. See LICENSE for details.