# 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
```sh
cargo add confroid
# for documentation generation:
cargo add confroid --features docs
```
## Quick start
```rust
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:
```rust
#[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:
```rust
#[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:
```rust
#[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:
```rust
#[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`):
```rust
#[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:
```rust
#[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:
```rust
#[derive(confroid::Config)]
struct Config {
people: HashMap<String, Person>,
}
#[derive(confroid::Config)]
struct Person {
name: String,
age: u8,
}
```
```bash
PEOPLE__alice__NAME=Alice
PEOPLE__alice__AGE=30
PEOPLE__bob__NAME=Bob
PEOPLE__bob__AGE=25
```
becomes
```rust
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:
```rust
#[derive(confroid::Config)]
struct Config {
names: Vec<String>,
}
```
```bash
NAMES__0=Alice
NAMES__1=Bob
```
becomes
```rust
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:
```rust
#[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 `,`):
```diff
#[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`:
```rust
#[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:
```rust
#[derive(confroid::Config)]
struct Config {
servers: Vec<Server>,
}
#[derive(confroid::Config)]
struct Server {
host: String,
port: u16,
}
```
```bash
SERVERS__0__HOST=a.example.com
SERVERS__0__PORT=80
SERVERS__1__HOST=b.example.com
SERVERS__1__PORT=443
```
becomes
```rust
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 (`u8`–`u128`, `usize`, `i8`–`i128`, `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
```toml
[dependencies]
confroid = { version = "0.0.2", features = ["humantime", "url"] }
```
Duration parsing is explicitly enabled per field:
```rust
#[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`:
```rust
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`](crates/confroid/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:
```toml
[dependencies]
confroid = { version = "0.0.2", features = ["docs"] }
```
and call one of the generators (using the `Config` struct from the quick-start example):
```rust
// A `.env.example` style file.
println!("{}", confroid::env_example::<Config>());
// A Markdown table.
println!("{}", confroid::markdown_table::<Config>());
```
`env_example::<Config>()` produces:
```bash
# 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:
| `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:
```rust
// 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](LICENSE-APACHE) or [MIT license](LICENSE-MIT) at your option.
[`Default::default()`]: https://doc.rust-lang.org/std/default/trait.Default.html
[`FromStr`]: https://doc.rust-lang.org/std/str/trait.FromStr.html