Templates
Concept Overview
A spec type already encodes the whole configuration surface. It names every field, holds every default, and carries the doc comment you wrote on each field. Once you have a spec, you can run parsing backward and turn it into a configuration file. This backward direction is called populate.
to_fields produces a plain configuration file with every default filled in.
to_template produces the same file with each field's documentation rendered as a comment above it, so an operator who opens the file learns what every setting means.
Either one is useful when you build a CLI command that writes a starter config, or when you want to show what the spec resolved to once its defaults were applied.
The crate ships a templates example that parses a two-line config and emits it back as an annotated TOML template.
Run it with:
cargo run -q -p confval --example templates --features derive,color,toml
The source sets only hostname and port.
Populate fills workers and tls from their defaults and fills the whole limits block, and emit renders each field's comment above it:
# The address the server binds to.
hostname = "127.0.0.1"
# The port the server listens on.
port = 8080
# The number of worker threads.
workers = 4
# Whether TLS is enabled.
tls = false
# Request size and mode limits.
[limits]
# The maximum request body size, in megabytes.
max_body_mb = 16
# How limit violations are handled.
mode = "enforce"
The rest of this page covers how each part works.
Generating a Template
to_template is the method you call to produce an annotated template.
It is generated by #[derive(Spec)], and the prelude exports the ToFields trait that declares it, so spec.to_template() works wherever the prelude is in scope.
It returns a Fields, the same format-neutral field model a frontend produces when it parses a file, so the ordinary emit functions render it.
use confval::format::toml::{emit_toml, parse_toml};
let spec: ServerSpec = parse_toml(&sources, id, &mut report).unwrap();
let template = emit_toml(&spec.to_template())?;
emit_hcl renders the same model as HCL.
A comment is indented to line up with the field it documents, so a comment inside a block is at the block's indentation:
# The address the server binds to.
hostname = "127.0.0.1"
# The port the server listens on.
port = 8080
# The number of worker threads.
workers = 4
# Whether TLS is enabled.
tls = false
# Request size and mode limits.
limits {
# The maximum request body size, in megabytes.
max_body_mb = 16
# How limit violations are handled.
mode = "enforce"
}
TOML content is flat, so in a TOML template every comment is at column zero.
Emit writes canonical text rather than rewriting a file a person authored.
It drops the comments and layout the field model never held, and it writes a nested struct as a [table] in TOML or a block in HCL.
Writing the Comments
A field's comment comes from its Rust doc comment, so you write the documentation once and it serves both the code and the template.
#[derive(confval::Spec)]
struct ServerSpec {
/// The port the server listens on.
port: Located<i64>,
/// Request size and mode limits.
#[confval(nested, default)]
limits: Option<Located<LimitsSpec>>,
}
A multi-line doc comment renders as one # line per source line, and a blank line inside the comment renders as a bare #.
When the template text should read differently from the rustdoc, set it on the field with #[confval(doc = "...")], and that text is used in place of the doc comment.
The Plain Dump
When you want the configuration file without any commentary, call to_fields instead.
It returns the same populated Fields as to_template but with no comments attached, so the emitted file is a clean dump of values.
let text = emit_toml(&spec.to_fields())?;
The two methods share one populated model and differ only in the comment lines, so the plain dump and the annotated template always describe the same configuration.
Because the model is the same Fields type parsing produces, anything that reads a parsed field model reads a populated one the same way.
hostname = "127.0.0.1"
port = 8080
workers = 4
tls = false
[limits]
max_body_mb = 16
mode = "enforce"
What Gets Filled
Populate emits a field only when there is a value to show, and the rules follow from what the parser leaves in the spec:
- A required field is always present, so it is always emitted.
- A leaf with an attribute default is emitted with that default, because parsing already filled it when the source omitted the field.
- An optional field that has no default and that the source left unset is omitted, because there is nothing to show.
- A repeated block is emitted once per element, and an empty list emits nothing.
- An optional block is filled only when you mark it, which the next section covers.
A block that is present is populated in turn, so a block you wrote but left partial gains its own absent defaults. A block that is filled is populated to full depth, so one call at the root resolves a nested tree of defaults all the way down.
Marking Optional Blocks
An optional block is absent until you write it.
Parsing keeps an absent Option<Located<S>> as None, so a spec read back from a file stays faithful to what the operator wrote.
Populate has to know which absent blocks to fill and which to leave out, because a block the runtime never applies should not appear in a populated view.
The #[confval(nested, default)] marker on an optional nested field is that signal.
A marked block is filled from its type's Default when you populate, and an unmarked optional block is left absent.
#[derive(confval::Spec)]
struct ServerSpec {
hostname: Located<String>,
port: Located<i64>,
#[confval(nested, default)]
limits: Option<Located<LimitsSpec>>,
#[confval(nested)]
telemetry: Option<Located<TelemetrySpec>>,
}
Here limits is filled from LimitsSpec::default() and telemetry is left absent.
A marked block requires its inner type to implement Default.
Deriving that with #[confval(derive_default)] generates the impl from the same attribute defaults, so one declaration drives parsing, Default, and the populated output together.
The marker changes populate only, so parsing still leaves an absent block None and the read path is unchanged.
When Emit Fails
Emit returns a Result, because not every field model has a faithful spelling in every format.
On the populate path the risk is small, and it depends on the format.
Emitting a populated spec to TOML always succeeds.
TOML has a literal for every value populate produces and quotes any key, so emit_toml(&spec.to_template())? cannot fail on a populated model.
Emitting a populated spec to HCL fails only for two numeric values HCL has no literal for.
The first is i64::MIN, which HCL would spell as a negation that overflows when it is read back.
The second is a non-finite float, an infinity or a NaN, which HCL has no keyword for.
A spec that holds neither emits to HCL without failing.
Emit can also fail on a Fields that a frontend parsed rather than populated, because a parsed model can carry a name or a value the target format cannot spell.
A value with no representation, such as a TOML datetime, fails in any format.
A name that is not a valid identifier fails when you emit HCL, which has no way to quote it, while TOML quotes it without trouble.
HCL also spells a value and a block side by side under one name.
A TOML key names one thing, so emit_toml refuses that pair rather than silently dropping one of the two.
A name used twice for plain values has no spelling in either format, so both emitters refuse it as well.
Each emit error names the dotted path of the field responsible, so a failure in a large tree points at its location.
A tree assembled by layering can carry unparsed text from an environment variable or a command line flag. That text emits as a string literal, since its type was never decided. A typed reparse of the emitted file therefore reads those leaves as strings.
Detached Spans and the Fixed Point
Every value populate produces carries a detached span, a span with no source location, because the value comes from the spec and not from a file. A parsed value points at the bytes it came from, while a populated value has no bytes to point at, so its span records only that the value was filled.
Equality on a Located value ignores the span, so a populated spec compares equal by value to the same spec parsed back from its own emitted output.
Populate only fills defaults, so running it on a spec that is already complete adds nothing.
Populate is therefore a fixed point.
Populate a spec, emit it, parse it back, and populate again, and you arrive at the same configuration.