DEV Community

Cover image for rclap a new configuration management for rust, is now on crates.io
Slim Ouertani
Slim Ouertani

Posted on

rclap a new configuration management for rust, is now on crates.io

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)
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode
// Specify path attribute to use a specific config file
#[config(path = "config.toml")]
struct Config;
// Or use the default config.toml
#[config]
struct Config;
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode
  1. Zero Drift: If the TOML changes in a way that breaks the application logic, the build fails.
  2. DevOps Friendly: SREs can look at config.toml to see exactly what the app expects, including documentation and environment variable mappings.
  3. 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" }
Enter fullscreen mode Exit fullscreen mode

Generated code:

#[config]
struct AppConfig;

// Generates:
// struct AppConfig {
//     s: Option<MySecondConfig>,
//     database: DatabaseConfig,
//     redis: RedisConfig,
// }
Enter fullscreen mode Exit fullscreen mode

With external types:

# Reference external config types
service_a = { type = "crate::apis::ServiceAConfig" }
service_b = { type = "external_crate::ServiceBConfig", default = "{...}" }
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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
// }
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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"] }

Enter fullscreen mode Exit fullscreen mode

Or with derives attribute like:

#[config(derives=[serde::Serialize, serde::Deserialize])]
struct MyConfig;
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
// }
Enter fullscreen mode Exit fullscreen mode

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"),
}
Enter fullscreen mode Exit fullscreen mode

Secret Features:

  • StringSecret for String-based secrets (passwords, tokens)
  • Secret<T> generalization for any type
  • .expose_secret() method to reveal value when needed
  • Compatible with both secrecy feature 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)