A flexible, type-safe configuration management library for Rust applications, built with hexagonal architecture principles.
- Multiple Configuration Sources: Environment variables, YAML files, command-line arguments, etcd, and Redis
- Type Safety: Automatic type conversions with comprehensive error handling
- Priority-Based Precedence: CLI arguments override environment variables, which override configuration files
- Dynamic Reloading: Watch configuration files, etcd, and Redis for changes and reload automatically
- Hexagonal Architecture: Clean separation of concerns with domain, ports, and adapters
- Extensible: Easy to implement custom configuration sources via traits
- Async Support: Built-in support for async remote sources (etcd, Redis)
Add this to your Cargo.toml:
[dependencies]
hexcfg = "1.1.4"use hexcfg::prelude::*;
fn main() -> Result<()> {
// Create a configuration service with environment variables
let service = DefaultConfigService::builder()
.with_env_vars()
.build()?;
// Get a configuration value (convenience method with string slice)
let app_name = service.get_str("app.name")?;
println!("Application name: {}", app_name.as_str());
// Or use ConfigKey explicitly if preferred
let app_name = service.get(&ConfigKey::from("app.name"))?;
println!("Application name: {}", app_name.as_str());
// Get with type conversion
let port = service.get_str("app.port")?;
let port_number: i32 = port.as_i32("app.port")?;
// Use default values for optional configuration (convenience method)
let log_level = service.get_or_default_str("log.level", "info");
// Check if a key exists (convenience method)
if service.has_str("app.debug") {
println!("Debug mode is configured");
}
Ok(())
}The crate uses feature flags to enable optional functionality:
| Feature | Description | Default |
|---|---|---|
yaml |
YAML file support via serde_yaml | ✅ |
env |
Environment variable support | ✅ |
cli |
Command-line argument support | ✅ |
reload |
Dynamic reloading with file watching | ❌ |
etcd |
etcd remote configuration support | ❌ |
redis |
Redis remote configuration support | ❌ |
remote |
All remote sources (etcd + redis) | ❌ |
full |
All features | ❌ |
[dependencies]
hexcfg = { version = "1.1.4", default-features = false, features = ["yaml", "env"] }This crate follows hexagonal architecture principles:
┌───────────────────────────────────────────────────┐
│ Application │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Domain Layer │ │
│ │ │ │
│ │ • ConfigKey, ConfigValue (core types) │ │
│ │ • ConfigurationService (business logic) │ │
│ │ • ConfigError (error types) │ │
│ │ │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Ports Layer │ │
│ │ │ │
│ │ • ConfigSource trait (source interface) │ │
│ │ • ConfigWatcher trait (watcher interface) │ │
│ │ • ConfigParser trait (parser interface) │ │
│ │ │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Adapters Layer │ │
│ │ │ │
│ │ • YamlFileAdapter │ │
│ │ • EnvVarAdapter │ │
│ │ • CommandLineAdapter │ │
│ │ • EtcdAdapter │ │
│ │ • RedisAdapter │ │
│ │ • FileWatcher, EtcdWatcher, RedisWatcher │ │
│ │ │ │
│ └─────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
For more ergonomic usage, the crate provides _str variants of common methods that accept string slices directly:
use hexcfg::prelude::*;
fn main() -> Result<()> {
let service = DefaultConfigService::builder()
.with_env_vars()
.build()?;
// Use string slices directly without creating ConfigKey
let value = service.get_str("app.name")?; // Instead of get(&ConfigKey::from("app.name"))
let value = service.get_or_default_str("log.level", "info"); // Instead of get_or_default(&ConfigKey::from(...), ...)
let exists = service.has_str("app.debug"); // Instead of has(&ConfigKey::from("app.debug"))
// Also available for ConfigSource trait
let adapter = EnvVarAdapter::new();
let value = adapter.get_str("database.host")?; // Instead of get(&ConfigKey::from("database.host"))
Ok(())
}Both approaches work - use whichever feels more natural for your code style!
Combine multiple sources with automatic precedence handling:
use hexcfg::prelude::*;
fn main() -> Result<()> {
let service = DefaultConfigService::builder()
.with_yaml_file("/etc/myapp/config.yaml")?
.with_env_vars()
.with_cli_args(std::env::args().collect())
.build()?;
// CLI args (priority 3) override env vars (priority 2),
// which override YAML files (priority 1)
let value = service.get(&ConfigKey::from("database.host"))?;
Ok(())
}Watch configuration files for changes:
use hexcfg::prelude::*;
use std::sync::{Arc, Mutex};
fn main() -> Result<()> {
let service = Arc::new(Mutex::new(
DefaultConfigService::builder()
.with_yaml_file("/etc/myapp/config.yaml")?
.build()?
));
let mut watcher = FileWatcher::new(
"/etc/myapp/config.yaml",
None // Use default debounce delay
)?;
let service_clone = Arc::clone(&service);
watcher.watch(Arc::new(move |_key| {
println!("Configuration changed, reloading...");
if let Ok(mut svc) = service_clone.lock() {
let _ = svc.reload();
}
}))?;
// Application continues running with live config updates
Ok(())
}Automatic type conversion with error handling:
use hexcfg::prelude::*;
fn main() -> Result<()> {
let service = DefaultConfigService::builder()
.with_env_vars()
.build()?;
// String value (no conversion)
let name = service.get(&ConfigKey::from("app.name"))?;
println!("Name: {}", name.as_str());
// Integer conversion
let port = service.get(&ConfigKey::from("app.port"))?;
let port_i32: i32 = port.as_i32("app.port")?;
let port_u16: u64 = port.as_u64("app.port")?;
// Boolean conversion
let debug = service.get(&ConfigKey::from("app.debug"))?;
let debug_mode: bool = debug.as_bool("app.debug")?;
// Float conversion
let timeout = service.get(&ConfigKey::from("api.timeout"))?;
let timeout_secs: f64 = timeout.as_f64("api.timeout")?;
Ok(())
}Filter environment variables by prefix:
use hexcfg::prelude::*;
fn main() -> Result<()> {
// Only read environment variables starting with "MYAPP_"
// MYAPP_DATABASE_HOST becomes "database.host"
let service = DefaultConfigService::builder()
.with_env_prefix("MYAPP_")
.build()?;
Ok(())
}use hexcfg::prelude::*;
#[tokio::main]
async fn main() -> Result<()> {
let service = DefaultConfigService::builder()
.with_etcd(vec!["localhost:2379"], Some("myapp/")).await?
.build()?;
// Configuration is now loaded from etcd
Ok(())
}use hexcfg::prelude::*;
#[tokio::main]
async fn main() -> Result<()> {
let service = DefaultConfigService::builder()
.with_redis(
"redis://localhost:6379",
"myapp:",
RedisStorageMode::StringKeys
).await?
.build()?;
// Configuration is now loaded from Redis
Ok(())
}Watch for configuration changes in etcd using its native watch API:
use hexcfg::prelude::*;
use hexcfg::adapters::EtcdWatcher;
use hexcfg::ports::ConfigWatcher;
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() -> Result<()> {
let service = Arc::new(Mutex::new(
DefaultConfigService::builder()
.with_etcd(vec!["localhost:2379"], Some("myapp/")).await?
.build()?
));
let mut watcher = EtcdWatcher::new(
vec!["localhost:2379"],
Some("myapp/")
).await?;
let service_clone = Arc::clone(&service);
watcher.watch(Arc::new(move |key| {
println!("Configuration changed in etcd: {}", key);
if let Ok(mut svc) = service_clone.lock() {
let _ = svc.reload();
}
}))?;
// Application continues running with live config updates from etcd
Ok(())
}Watch for configuration changes in Redis using keyspace notifications:
use hexcfg::prelude::*;
use hexcfg::adapters::RedisWatcher;
use hexcfg::ports::ConfigWatcher;
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() -> Result<()> {
let service = Arc::new(Mutex::new(
DefaultConfigService::builder()
.with_redis(
"redis://localhost:6379",
"myapp:",
RedisStorageMode::StringKeys
).await?
.build()?
));
let mut watcher = RedisWatcher::new(
"redis://localhost:6379",
"myapp:"
)?;
// Try to enable keyspace notifications (requires CONFIG permission)
let _ = watcher.try_enable_keyspace_notifications();
let service_clone = Arc::clone(&service);
watcher.watch(Arc::new(move |key| {
println!("Configuration changed in Redis: {}", key);
if let Ok(mut svc) = service_clone.lock() {
let _ = svc.reload();
}
}))?;
// Application continues running with live config updates from Redis
Ok(())
}Note: Redis keyspace notifications must be enabled on the Redis server:
# Via redis-cli
CONFIG SET notify-keyspace-events KEA
# Or in redis.conf
notify-keyspace-events KEAImplement the ConfigSource trait to create custom sources:
use hexcfg::ports::ConfigSource;
use hexcfg::domain::{ConfigKey, ConfigValue, Result};
struct MyCustomSource;
impl ConfigSource for MyCustomSource {
fn name(&self) -> &str {
"my-custom-source"
}
fn priority(&self) -> u8 {
1 // Lower than env vars but same as files
}
fn get(&self, key: &ConfigKey) -> Result<Option<ConfigValue>> {
// Your custom logic here
Ok(None)
}
fn all_keys(&self) -> Result<Vec<ConfigKey>> {
Ok(vec![])
}
fn reload(&mut self) -> Result<()> {
// Reload logic if applicable
Ok(())
}
}Configuration sources have priorities that determine precedence:
| Priority | Source | Description |
|---|---|---|
| 3 | CLI Arguments | Highest priority, overrides all others |
| 2 | Environment Variables | Overrides files and remote sources |
| 1 | Files & Remote | YAML, etcd, Redis - lowest priority |
When multiple sources provide the same key, the value from the highest priority source is used.
The crate provides comprehensive error types via thiserror:
use hexcfg::prelude::*;
fn load_config() -> Result<()> {
let service = DefaultConfigService::builder()
.with_yaml_file("/etc/myapp/config.yaml")?
.build()?;
match service.get(&ConfigKey::from("database.host")) {
Ok(value) => println!("Host: {}", value.as_str()),
Err(ConfigError::ConfigKeyNotFound { key }) => {
eprintln!("Missing required configuration: {}", key);
}
Err(e) => eprintln!("Configuration error: {}", e),
}
Ok(())
}The crate includes several examples:
# Basic usage with environment variables
export APP_NAME="MyApp"
export APP_PORT="8080"
cargo run --example basic_usage --features env
# String convenience methods
export APP_NAME="MyApp"
export APP_PORT="8080"
cargo run --example string_convenience --features env
# Multiple sources with precedence
cargo run --example multi_source --features yaml,env,cli -- --app.name=CliApp
# Dynamic reloading
cargo run --example dynamic_reload --features yaml,reloadRun tests with different feature combinations:
# Run all tests with default features
cargo test
# Run tests with all features
cargo test --all-features
# Run tests with specific features
cargo test --features yaml,env,cli
# Run property-based tests
cargo test --test proptest_testsIntegration tests for etcd and Redis watchers are included in their respective integration test files and use Docker containers automatically via testcontainers-rs. These tests will automatically skip if Docker is not available:
# Run all Redis tests (including watcher tests)
cargo test --test redis_integration_tests --all-features
# Run all etcd tests (including watcher tests)
cargo test --test etcd_integration_tests --all-features
# Run specific watcher tests
cargo test --test redis_integration_tests test_redis_watcher --all-features
cargo test --test etcd_integration_tests test_etcd_watcher --all-featuresDocker must be installed and running for these tests to execute. If Docker is unavailable, the tests will be skipped with a warning message.
Generate and view the full API documentation:
cargo doc --open --all-featuresContributions are welcome! Please ensure:
- All tests pass:
cargo test --all-features - Code is formatted:
cargo fmt --check - No clippy warnings:
cargo clippy --all-features - Documentation is updated for public APIs
Licensed under:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
Any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed under the terms of the Apache-2.0 license, without any additional terms or conditions.