Skip to main content

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:

  1. A const slice of the allowed strings, checked by KeywordSet during validation.
  2. A runtime enum the config type holds.
  3. 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 derive and at least one frontend feature, toml or hcl, for the format you emit.
  • Configuration layering is behind the layering flag.
[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.