confroid 0.0.4

The n+1-st config reader for your environment-based configs.
Documentation

confroid

Your config on 'roids

confroid is the n+1-st config reader for your environment-based configs.

confroid derives an environment-variable reader from a plain Rust struct. It is type-driven: nesting, collections, and optionality all come from the field's type, so there is very little to annotate.

Installation

cargo add confroid
# for documentation generation:
cargo add confroid --features docs

Quick start

const DEFAULT_NAME: &str = "World";

#[derive(confroid::Config)]
struct HttpConfig {
    #[confroid(default = 8080)]
    /// The port to listen on.
    port: u16,
    #[confroid(example = "localhost")]
    /// The host to listen on.
    host: String,
}

#[derive(confroid::Config)]
struct Config {
    /// The HTTP configuration.
    http: HttpConfig,
    #[confroid(default = "Hello")]
    /// The greeting to use.
    greeting: String,
    #[confroid(default = DEFAULT_NAME, example = "John")]
    /// The name of the user.
    name: String,
}

fn main() {
    let config: Config = confroid::from_env().unwrap();
    println!("{}, {}!", config.greeting, config.name);
}

With only HTTP__HOST=localhost set, this reads http.port as 8080, greeting as Hello, and name as World.

The entry points are:

  • confroid::from_env() — read from the process environment.
  • confroid::from_pairs(pairs) — read from an explicit set of key/value pairs (handy if you have a custom config source).

Error reporting

Confroid reads every independent field before returning an error. When multiple variables are missing or invalid, ConfroidError::Multiple contains all of the failures in declaration order; nested structs and collections are flattened into the same report. Its Display output lists every problem, so a deployment can be fixed in one pass rather than one restart per variable.

Invalid-value errors retain the concrete FromStr error as their std::error::Error::source, allowing reporters such as anyhow and color-eyre to show the parser's cause chain. Use ConfroidError::errors() to iterate over either a single leaf error or all errors in an aggregate uniformly.

Naming

Every field maps to an environment variable named after the field in SCREAMING_SNAKE_CASE. Nesting is detected from the field's type and nested fields prepend their own name as a prefix, joined with __, so http.port becomes HTTP__PORT.

Add a prefix to every variable represented by a struct with a container-level prefix attribute. This is useful for namespacing an application's environment:

#[derive(confroid::Config)]
#[confroid(prefix = "GUPPY")]
struct Config {
    // Read from `GUPPY__PORT`.
    port: u16,
}

Override an individual field's derived name with the name attribute. Renaming a nested field renames the whole prefix, so its children follow along:

#[derive(confroid::Config)]
struct Config {
    #[confroid(name = "LISTEN_PORT")]
    /// Read from `LISTEN_PORT` instead of `PORT`.
    port: u16,
    #[confroid(name = "SERVER")]
    /// Nested fields are read as `SERVER__*` instead of `HTTP__*`
    /// (e.g. `http.port` -> `SERVER__PORT`).
    http: HttpConfig,
}

Defaults

Use default = <expr> to supply an explicit fallback, or the bare default attribute (no value) to fall back to Default::default() for the field's type:

#[derive(confroid::Config)]
struct Config {
    // Unset -> 8080
    #[confroid(default = 8080)]
    port: u16,
    // Unset -> 0 (u16::default())
    #[confroid(default)]
    retries: u16,
}

A default only applies when the variable is unset — a value that is present but fails to parse is always an error.

Optional fields

An Option<T> field is read from presence:

#[derive(confroid::Config)]
struct Config {
    name: Option<String>,
}
  • if you don't set NAME, name is None
  • if NAME is present but empty, name is also None
  • if you set NAME to a non-empty string, name is Some(value)

Treating an empty optional leaf as absent matches .env examples and deployment tools such as Compose, Helm, and CI templating, which commonly materialize unset variables as empty strings. Required String fields still preserve an explicit empty value; required parsed fields receive it and report a parse error when it is invalid.

Combining Option with default

An Option field with a default can never be None when the variable is unset, since the default takes over (unless when the default itself resolves to None):

#[derive(confroid::Config)]
struct Config {
    // Unset -> Some("anonymous")
    #[confroid(default = "anonymous")]
    name: Option<String>,
    // Unset -> None, because Option::default() is None
    #[confroid(default)]
    nickname: Option<String>,
}

Optional structs

For an Option around a nested struct there is no single variable to test, so presence is decided by its children:

#[derive(confroid::Config)]
struct Config {
    tls: Option<TlsConfig>,
}

#[derive(confroid::Config)]
struct TlsConfig {
    cert: String,
    key: String,
}
  • if none of TLS__* are set, tls is None
  • if any of TLS__* are set, the struct is parsed normally — so a partially-set struct (e.g. TLS__CERT without TLS__KEY) is an EnvVarNotFound error rather than silently becoming None

Collections

HashMap

Confroid supports HashMap fields out of the box. Map keys are taken verbatim from the variable name (case preserved), while field names are still uppercased:

#[derive(confroid::Config)]
struct Config {
    people: HashMap<String, Person>,
}

#[derive(confroid::Config)]
struct Person {
    name: String,
    age: u8,
}
PEOPLE__alice__NAME=Alice
PEOPLE__alice__AGE=30
PEOPLE__bob__NAME=Bob
PEOPLE__bob__AGE=25

becomes

assert_eq!(config.people.len(), 2);
assert_eq!(config.people.get("alice").unwrap().name, "Alice");
assert_eq!(config.people.get("bob").unwrap().name, "Bob");

Vectors

By default a Vec is read from contiguous indexed variables, no configuration required:

#[derive(confroid::Config)]
struct Config {
    names: Vec<String>,
}
NAMES__0=Alice
NAMES__1=Bob

becomes

assert_eq!(config.names.len(), 2);
assert_eq!(config.names[0], "Alice");
assert_eq!(config.names[1], "Bob");

Indices must be contiguous starting at 0. A gap (e.g. NAMES__0 and NAMES__2 with no NAMES__1) is an error rather than a silently compacted list.

A Vec field is required: if no matching variables are set and the field has no default, it is treated as missing and returns the same EnvVarNotFound error as any other required variable. Use #[confroid(default)] to fall back to an empty vector instead:

#[derive(confroid::Config)]
struct Config {
    // No NAMES__* set -> Vec::new()
    #[confroid(default)]
    names: Vec<String>,
}

Delimited values

For scalar elements you can opt into a single delimited variable with auto_vec (default delimiter ,):

#[derive(confroid::Config)]
struct Config {
+   #[confroid(auto_vec)]
    names: Vec<String>,
}

so NAMES=Alice,Bob becomes names: vec!["Alice", "Bob"].

Choose a different delimiter with auto_vec_delimiter:

#[derive(confroid::Config)]
struct Config {
    #[confroid(auto_vec, auto_vec_delimiter = ";")]
    names: Vec<String>,
}

auto_vec only applies to scalar element types, since a delimited value cannot express a struct's fields.

Vectors of structs

The indexed form composes with nested structs — each index is its own prefix:

#[derive(confroid::Config)]
struct Config {
    servers: Vec<Server>,
}

#[derive(confroid::Config)]
struct Server {
    host: String,
    port: u16,
}
SERVERS__0__HOST=a.example.com
SERVERS__0__PORT=80
SERVERS__1__HOST=b.example.com
SERVERS__1__PORT=443

becomes

assert_eq!(config.servers.len(), 2);
assert_eq!(config.servers[0].host, "a.example.com");
assert_eq!(config.servers[1].port, 443);

Supported types

Out of the box, confroid reads:

  • integers (u8u128, usize, i8i128, isize), floats (f32, f64), bool, char, String, Box<str>, and Arc<str>
  • NonZeroU16, useful for ports that must not be zero
  • std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6} and std::path::PathBuf
  • Option<T>, Vec<T>, and HashMap<String, T> for any supported T
  • any struct that derives confroid::Config

Two optional features add external scalar types:

  • humantime enables #[confroid(humantime)] on std::time::Duration and Option<std::time::Duration> fields, accepting values such as 30s, 5m, or 1h 15m via the humantime crate
  • url parses url::Url values using the url crate
[dependencies]
confroid = { version = "0.0.2", features = ["humantime", "url"] }

Duration parsing is explicitly enabled per field:

#[derive(confroid::Config)]
struct Config {
    #[confroid(humantime)]
    timeout: std::time::Duration,
}

Scalars are parsed via FromStr. To read a custom scalar type — an enum, a newtype, anything with a FromStr — implement FromStr for it and derive ConfigValue:

use std::str::FromStr;

/// How chatty the logs are.
#[derive(confroid::ConfigValue, Debug, Clone, Copy)]
enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
    Trace,
}

impl FromStr for LogLevel {
    type Err = std::io::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_ascii_lowercase().as_str() {
            "error" => Ok(Self::Error),
            "warn" => Ok(Self::Warn),
            "info" => Ok(Self::Info),
            "debug" => Ok(Self::Debug),
            "trace" => Ok(Self::Trace),
            other => Err(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!("unknown log level `{other}`"),
            )),
        }
    }
}

#[derive(confroid::Config)]
struct Config {
    #[confroid(default = LogLevel::Info)]
    log_level: LogLevel,
}

so LOG_LEVEL=debug parses into LogLevel::Debug. The FromStr error must implement std::error::Error + Send + Sync + 'static; confroid preserves it as the source of an invalid-value error. With the docs feature, a ConfigValue must also implement Display so defaults and examples can be rendered.

The existing confroid::from_str_scalar!(Type) macro remains available when adding a derive at the type definition is not convenient.

A full runnable version is in examples/enum_config.rs. (If you enable the docs feature, also implement Display for the type so its default and example can be rendered.)

Generating documentation

Enable the docs feature:

[dependencies]
confroid = { version = "0.0.2", features = ["docs"] }

and call one of the generators (using the Config struct from the quick-start example):

// A `.env.example` style file.
println!("{}", confroid::env_example::<Config>());
// A Markdown table.
println!("{}", confroid::markdown_table::<Config>());

env_example::<Config>() produces:

# Config configuration

# The HTTP configuration.

# The port to listen on. (default: 8080)
# HTTP__PORT=8080
# The host to listen on. (example: localhost)
HTTP__HOST=

# The greeting to use. (default: Hello)
# GREETING=Hello

# The name of the user. (default: World, example: John)
# NAME=World

Optional fields and fields with defaults are emitted as commented-out assignments. Required fields remain active.

markdown_table::<Config>() produces:

Variable Description Default Example
HTTP__PORT The port to listen on. 8080
HTTP__HOST The host to listen on. localhost
GREETING The greeting to use. Hello
NAME The name of the user. World John

Default and example annotations are rendered only for scalar fields. Vec fields appear as a single variable line without them. HashMap fields use <key> to show where the map key belongs; maps of structs also include the value struct's nested fields.

Errors

from_env returns Result<T, ConfroidError>. Every error names both the environment variable and the dotted field path:

// A required variable is not set.
ConfroidError::EnvVarNotFound { var_name: "HTTP__PORT", field: "http.port" }

// A variable is set but its value cannot be parsed.
ConfroidError::EnvVarInvalid { var_name: "HTTP__PORT", field: "http.port", value: "abc", parser_error: /* ... */ }

// A vector has a gap in its indices.
ConfroidError::VecIndexGap { var_name: "NAMES", field: "names", missing: 1 }

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.