| Crates.io | hexcfg |
| lib.rs | hexcfg |
| version | 1.1.4 |
| created_at | 2025-10-07 10:11:58.641053+00 |
| updated_at | 2025-10-07 14:48:13.904165+00 |
| description | A hexagonal architecture configuration loading crate with multi-source support |
| homepage | https://github.com/cryptidtech/hexcfg |
| repository | https://github.com/cryptidtech/hexcfg |
| max_upload_size | |
| id | 1871452 |
| size | 439,662 |
A flexible, type-safe configuration management library for Rust applications, built with hexagonal architecture principles.
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.3", 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 KEA
Implement 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,reload
Run 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_tests
Integration 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-features
Docker 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-features
Contributions are welcome! Please ensure:
cargo test --all-featurescargo fmt --checkcargo clippy --all-featuresLicensed under either of:
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
This crate is ready for use and includes:
This crate was designed with inspiration from configuration management libraries in other ecosystems, adapted to Rust's ownership model and type system.