confval v0.6.0
This release adds template generation, multi-source configuration layering, the keyword_enum! macro, and a way to derive a spec's Default from its attribute defaults.
Highlights
Render a spec back to a config file
A spec already names every field, holds every default, and carries the doc comment you wrote on each field.
Template generation runs that backward and turns a spec into configuration text.
to_fields builds a populated file with every default filled in.
to_template builds the same file with each field's documentation rendered as a comment above it.
An emitter then serializes the model to a format, emit_toml for TOML and emit_hcl for HCL.
use confval::format::toml::emit_toml;
use confval::prelude::*;
#[derive(confval::Spec)]
struct ServerSpec {
#[confval(doc = "The address the server binds to.")]
hostname: Located<String>,
/// The port the server listens on.
port: Located<i64>,
/// The number of worker threads.
#[confval(default = 4)]
workers: Located<i64>,
/// Request size and mode limits.
#[confval(nested, default)]
limits: Option<Located<LimitsSpec>>,
}
fn write_config(spec: &ServerSpec) -> Result<(), String> {
// The plain dump fills every default and carries no comments.
let plain = emit_toml(&spec.to_fields()).map_err(|error| error.to_string())?;
print!("{plain}");
// The annotated template renders each field's doc comment above it.
let annotated = emit_toml(&spec.to_template()).map_err(|error| error.to_string())?;
print!("{annotated}");
Ok(())
}
A field's comment comes from its /// doc comment.
When the file text should read differently from the rustdoc, set it with the #[confval(doc = "...")] attribute.
That text is used in place of the doc comment.
#[derive(confval::Spec)]
struct ServerSpec {
#[confval(doc = "The address the server binds to.")]
/// This rustdoc is ignored, because the attribute wins.
hostname: Located<String>,
// ...
}
See Templates for what populate fills, how a comment is rendered per format, and when emit can fail.
Assemble configuration from many sources
A single file used to be the whole configuration.
Layering combines several sources into one spec, so a base file supplies the values, environment variables override them, and command line flags override those.
Each source is a provider that yields the same neutral Fields.
An Assembly folds the providers by precedence.
merge lets a later source replace an earlier one, and join lets an earlier source stand while a later one fills only what is still missing.
Precedence is the call order, so the assembly site defines the policy.
Every value keeps its byte span, so a diagnostic points at the exact source it came from, whether that is a file, an environment variable, or a flag.
use confval::format::toml::parse_toml_fields;
use confval::layering::{Assembly, cli_fields, env_fields};
use confval::prelude::*;
// `sources` holds the registered files, `base` and `defaults` are their ids,
// and `report` accumulates diagnostics.
let spec: Option<ServerSpec> = Assembly::new()
.merge(parse_toml_fields(&sources, base, &mut report))
.merge(env_fields(&mut sources, "APP_", &mut report))
.merge(cli_fields(&mut sources, ["--limits.mode=log".to_string()], &mut report))
.join(parse_toml_fields(&sources, defaults, &mut report))
.assemble(&mut report);
An environment variable selects by a prefix, separates levels with a double underscore, and keeps a single underscore inside a key, so APP_LIMITS__MAX_BODY_MB=64 sets limits.max_body_mb.
A command line flag uses --key=value with a dot between levels, so --limits.mode=log sets the nested mode.
A non-file value arrives as a string and is coerced to the field's declared type, so --tls=true sets a bool and APP_PORT=9090 sets an i64.
The feature is behind the layering flag.
With the flag off, the crate behaves the same as before.
Declare a keyword set and its enum in one place
A closed-set field used to need three artifacts kept in sync by hand:
- A const slice of the allowed strings, checked by
KeywordSetduring validation. - A runtime enum the config type holds.
- A
TryFrom<&str>impl that bridges the two at lowering.
When they drifted, a value could pass validation and then fail to lower, or lower to a variant validation would have rejected.
The keyword_enum! macro declares all three from one source.
keyword_enum!(pub LimitMode, {
Enforce => "enforce",
Log => "log",
Off => "off",
});
It generates the enum, a KEYWORDS slice, an as_str, a keyword_set constructor, a TryFrom<&str>, a Display, and a compile-time assertion that no keyword repeats.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LimitMode {
Enforce,
Log,
Off,
}
const _: () = ::core::assert!(
::confval::pipeline::keyword::__keyword_enum_keywords_unique(&["enforce", "log", "off"]),
"keyword_enum!: two variants map to the same keyword"
);
impl LimitMode {
/// The allowed keyword strings, in declaration order.
pub const KEYWORDS: [&'static str; 1usize + 1usize + 1usize + 0usize] =
["enforce", "log", "off"];
/// The keyword for this variant.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Enforce => "enforce",
Self::Log => "log",
Self::Off => "off",
}
}
/// A `KeywordSet` over `KEYWORDS`, for a `Validate` impl to check a
/// located value against.
pub fn keyword_set() -> ::confval::KeywordSet<'static> {
::confval::KeywordSet::new(&Self::KEYWORDS)
}
}
impl ::core::convert::TryFrom<&str> for LimitMode {
type Error = ();
fn try_from(value: &str) -> ::core::result::Result<Self, ()> {
match value {
"enforce" => ::core::result::Result::Ok(Self::Enforce),
"log" => ::core::result::Result::Ok(Self::Log),
"off" => ::core::result::Result::Ok(Self::Off),
_ => ::core::result::Result::Err(()),
}
}
}
impl ::core::fmt::Display for LimitMode {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.write_str(self.as_str())
}
}
At lowering, the narrow::keyword::<LimitMode> helper runs the generated TryFrom and reports at the value's span, so a keyword field needs no handwritten converter.
See Validation for how a keyword set reports an unknown value.
Derive Default from the attribute defaults
A defaultable spec declared its defaults twice.
The #[confval(default = ...)] attribute fills an absent field during parsing.
A handwritten impl Default supplied the whole struct when an enclosing block was absent, which #[confval(nested, default)] reads on the config side.
Nothing checked that the two agreed, so a block present with a field omitted and a block absent entirely could resolve to different values.
#[confval(derive_default)] generates the Default impl from the same attribute defaults.
One declaration then drives parsing, Default, and the populated template.
#[derive(confval::Spec)]
#[confval(derive_default)]
struct LimitsSpec {
#[confval(default = 16)]
max_body_mb: Located<i64>,
#[confval(default = "enforce".to_string())]
mode: Located<String>,
}
See Deriving Default from the attribute defaults for the rules.
Upgrading
Update the version and enable the features you use.
- The write path needs
deriveand at least one frontend feature,tomlorhcl, for the format you emit. - Configuration layering is behind the
layeringflag.
[dependencies]
confval = { version = "0.6.0", features = ["derive", "toml", "hcl", "color", "layering"] }
The release is additive, so existing code needs no change.
Use the new features, especially keyword_enum! and #[confval(derive_default)], to reduce boilerplate.