Introduction
Every deployment requires a set of environment variables to run correctly. Instead of scattering these across multiple files and manually tracking what each pod needs, we maintain a single centralized configuration file that serves as the definitive source for all required environment variables.
This approach provides two key benefits:
- Easy to check: Developers and operators can quickly identify what environment variables are required by any pod by referring to a single source
- Easy to hand over: When deployment configurations need to change, everything is consolidated in one place
Whether you're using Helm charts or other deployment tools, having a centralized configuration file simplifies the management of environment variables and arguments that are passed to your applications. This eliminates the need to coordinate across multiple configuration files and ensures consistency across environments.
Traditional clap struggles with this separation. Configuration is primarily code-based, making it difficult to hand over configuration files to DevOps teams. rclap solves this by generating configuration structures from TOML files while maintaining compile-time safety.
ZIO Config Experience
Before moving to Rust, our team relied on ZIO and Scala 3 ecosystem, the experience was remarkably ergonomic. We embraced ZIO Config from the start and the integration was seamless with typesafe config library:
Derived Configuration definition:
import zio.config.magnolia.deriveConfig
import zio.Config
final case class RedisConfig(host: String, port: Int, cacheSecretPath: String)
object RedisConfig:
given Config[RedisConfig] = deriveConfig[RedisAppConfig].map(_.redis)
private final case class RedisAppConfig(redis: RedisConfig)
Config provider setup:
import zio.config.typesafe.TypesafeConfigProvider
import zio.*
import com.typesafe.config.ConfigFactory
object ConfigProvider:
private def fileConfigProvider(root: String) =
TypesafeConfigProvider.fromTypesafeConfig(ConfigFactory.load.getConfig(root).resolve())
def configLayer(root: String = "dev"): ZLayer[Any, Nothing, Unit] =
Runtime.setConfigProvider(
fileConfigProvider(root) orElse zio.ConfigProvider.envProvider
)
As we transitioned to Rust, we looked for a tool that could match this level of declarative power—specifically, something that could reconcile the flexibility of external configuration with the strictness of Rust's type system.
Twelf Evaluation
We initially selected twelf for its powerful layer-based approach that effectively separates concerns: However, we eventually hit a friction point: Config-Code divergence.
Twelf's Key Features
Layer-based priority system:
use twelf::{config, Layer};
#[config]
struct Conf {
test: String,
another: usize,
}
let config = Conf::with_layers(&[
Layer::Json("conf.json".into()),
Layer::Env(Some("PREFIX_".to_string())),
Layer::Clap(app.get_matches().clone())
]).unwrap();
Multiple format support: JSON, YAML, TOML,..
Environment variable expansion:
# config.json with Twelf syntax
{
"database_url": "${DATABASE_URL:-postgres://localhost/db}",
"cache_ttl": "${CACHE_TTL:-300}"
}
Because the toml file and the Rust struct are defined independently, they can easily drift. If a DevOps engineer updates the toml schema, the binary might fail at runtime. We needed a way to move that failure to compile-time and detected by our CI.
rclap Created: Injected Configuration
rclap was born from the need to treat the configuration file as the single source of truth.
The Core Concept
Instead of writing a struct and hoping it matches your toml, the #[config] macro reads your toml file during compilation and generates the corresponding Rust code for you, ensuring that the code structure always matches the configuration:
# config.toml - the source of truth
ip = { default = "localhost", doc = "Connection URL", env = "IP" }
port = { type = "usize", default = "8080", doc = "Server port", env = "PORT" }
[redis]
doc = "Redis configuration"
url = { default = "redis://localhost:6379", env = "REDIS_URL" }
pool_size = { type = "int", default = "10" }
enum1 = { enum = "Mode", variants = ["debug", "production", "staging"], default = "debug" }
// Specify path attribute to use a specific config file
#[config(path = "config.toml")]
struct Config;
// Or use the default config.toml
#[config]
struct Config;
Generates:
struct Config {
#[arg(long = "ip", default_value = "localhost")]
pub ip: String,
#[arg(long = "port", default_value_t = 8080)]
pub port: usize,
#[command(flatten)]
pub redis: RedisConfig,
#[arg(value_enum, long, default_value = "debug")]
pub mode: Mode,
}
- Zero Drift: If the TOML changes in a way that breaks the application logic, the build fails.
- DevOps Friendly: SREs can look at config.toml to see exactly what the app expects, including documentation and environment variable mappings.
- Composition: Configurations can be nested and reused, allowing complex microservices to share common blocks (like Redis or Database settings) without duplication.
Service Composition with Nested Configs
rclap supports composing configurations through nested structures, allowing you to define hierarchical configurations.
Example:
# Simple nested configuration
s = { type = "crate::MySecondConfig", doc = "A nested configuration struct", optional = true }
# Multiple nested configs
[database]
type = "DatabaseConfig"
[database.connection]
url = { default = "localhost", env = "DB_URL" }
port = { type = "int", default = "5432" }
[redis]
type = "RedisConfig"
pool_size = { type = "int", default = "10" }
Generated code:
#[config]
struct AppConfig;
// Generates:
// struct AppConfig {
// s: Option<MySecondConfig>,
// database: DatabaseConfig,
// redis: RedisConfig,
// }
With external types:
# Reference external config types
service_a = { type = "crate::apis::ServiceAConfig" }
service_b = { type = "external_crate::ServiceBConfig", default = "{...}" }
This composition model enables:
- Hierarchical configuration organization
- Reuse of configuration blocks across services
- Optional nested configurations
- Integration with external configuration types
Practical Example: Multi-Environment Configuration
Config Example
This example demonstrates both mandatory fields (with defaults) and optional fields:
# ====================
# MANDATORY FIELDS WITH DEFAULTS
# ====================
app_name = { default = "myapp", env = "APP_NAME", doc = "Application name" }
debug = { type = "bool", default = "true", env = "DEBUG", doc = "Enable debug mode" }
[database]
# Mandatory field - will always be required with default
url = { default = "localhost", env = "DB_URL", doc = "Database connection URL" }
pool_size = { type = "int", default = "5", doc = "Connection pool size" }
# ====================
# OPTIONAL FIELDS
# ====================
# Optional field - can be omitted, uses default when not provided
cache_enabled = { type = "bool", default = "true", optional = true, env = "CACHE_ENABLED" }
# Optional field with no default - remains None if not specified
log_format = { type = "string", optional = true, env = "LOG_FORMAT" }
Generated Code
#[config]
struct Config;
// Generates:
// struct Config {
// app_name: String, // Mandatory with default
// debug: bool, // Mandatory with default
// database: DatabaseConfig, // Substruct (mandatory)
// cache_enabled: bool, // Optional with default
// log_format: Option<String>, // Optional, no default
// }
Usage
#[config]
struct Config;
fn main() {
// Automatically picks up from file or environment
let config = Config::parse();
// Access mandatory fields directly
println!("App Name: {}", config.app_name);
// Access optional fields
if let Some(format) = config.log_format {
println!("Log Format: {}", format);
} else {
println!("Log Format: unset");
}
// Access via iter_map for dynamic settings
let map = config.iter_map();
println!("Database URL: {}", map.get("database.url").unwrap());
println!("Pool Size: {}", map.get("database.pool_size").unwrap());
}
Serde Feature
rclap provides Serde integration through a feature flag and macro attributes, allowing you to derive additional traits for your configuration structs.
Enabling Serde in Cargo.toml:
rclap = { version = "1.2", features = ["serde"] }
Or with derives attribute like:
#[config(derives=[serde::Serialize, serde::Deserialize])]
struct MyConfig;
Secrets Management
The Secret Handling
Configuration File:
# Secure password handling
api_key = { default = "default-key", secret = true }
db_password = { default = "secret123", optional = true, secret = true }
tls_cert_path = { type = "path", default = "/etc/ssl/certs", secret = false }
Generated Code:
#[config]
struct SecureConfig;
// Generates:
// struct SecureConfig {
// pub api_key: Secret<String>,
// pub db_password: Option<Secret<String>>,
// pub tls_cert_path: PathBuf, // Regular value
// }
Usage with Secrets:
let config = SecureConfig::parse();
// The secret value is hidden from Debug/Display
println!("API Key: {:?}", config.api_key); // Shows Redacted Secret { ... }
println!("Password hidden: {:?}", config.db_password);
// Reveal when needed
let api_key = config.api_key.expose_secret();
println!("API Key: {}", api_key); // Shows actual value
// For StringSecret specifically
match config.db_password {
Some(secret) => println!("Password: {}", secret.expose_secret()),
None => println!("No password set"),
}
Secret Features:
-
StringSecretfor String-based secrets (passwords, tokens) -
Secret<T>generalization for any type -
.expose_secret()method to reveal value when needed - Compatible with both
secrecyfeature flag and built-in implementation - Optional secrets:
optional = true, secret = true
Conclusion
rclap wraps clap by providing a configuration-driven approach that maintains compile-time safety and ergonomic API design. By generating configuration structures from TOML files, rclap enables DevOps teams to manage centralized configurations effectively while providing developers with the type safety that Rust offers.
For teams using Helm and Kubernetes, rclap provides the flexibility needed for multi-environment deployments while maintaining the structure and safety that modern software development demands.
Design Philosophy: rclap is not intended to replace clap, as this is not its purpose. Instead, it leverages clap's strengths for configuration-specific use cases. The library is designed to evolve over time and include additional features and use cases as needed.
Starting with Twelf taught us the value of layer-based configuration. rclap builds on those insights while addressing the limitations encountered, particularly around optional fields, arguments support, and configuration-code alignment.
Top comments (0)