Skip to main content
Version: 0.7.x-dev

The Schema IR

Sometimes you need the type of a spec rather than a value of it. An editor writing completions is one example. Before an operator writes a value, the editor needs to know which fields are legal, which are required, what kind each one holds, and which values a closed-set field accepts.

The value walks cannot answer that. FromFields reads a Fields and builds a spec, and ToFields walks a spec and builds a Fields. Both need an instance, and a populated Fields holds values, not declared types.

The schema IR reads the type instead. ToSchema::schema() returns a Schema that describes the spec. It is an associated function with no self, so you call it without a value.

For example, list the top-level fields and whether each is required:

use confval::schema::ToSchema;

let schema = ServerSpec::schema();
for field in &schema.fields {
println!("{}: required={}", field.name, field.required);
}

What a schema carries

A Schema is one level of a spec: the type's doc comment and its fields in declaration order. Each SchemaField carries the field name as it appears in a config file, the field's doc comment, whether it is required, whether it declares a default, and its declared type.

The declared type is a SchemaType. A scalar leaf carries its ScalarType and any constraint it declares. A string list is StringList, and a string-keyed map is StringMap. A nested block is Block, which holds the child level's own Schema and a repeated flag for a zero-or-more block list.

A leaf reads its ScalarType from the Rust type, so port: Located<i64> is Int and hostname: Located<String> is String. A PathBuf leaf reads as Path, the name for the path string an operator writes.

A block recurses into the child's own schema(), so one call at the root builds the whole tree.

The schema does not carry rendered default values. It records only whether a field has a default. To read the concrete default text, use the template walk, ServerSpec::default().to_template().

When a field is required

required answers whether an absent field is a parse error. A field is required when its shape needs a value and it declares no default. An Option field, a zero-or-more block list, and any field with a #[confval(default)] are not required.

A defaulted field therefore reports required as false and has_default as true, whatever its shape. An editor reads required to report only the fields the parser would reject as missing.

Recording constraints

The derive cannot read a Validate body, so a closed-set field looks like a plain Located<String> and a numeric range is invisible to the schema. Two attributes record a constraint on a scalar leaf so the schema can carry it.

#[confval(keywords = PATH)] names a keyword_enum! type and requires a String leaf. The schema carries its allowed strings as Constraint::Keywords.

#[confval(range = PATH)] names a RangeConstraint and requires an Int or Float leaf. The schema carries its bounds, units, and help line as Constraint::Range.

For example, attach a range to two integer fields:

#[derive(confval::Spec)]
struct ServerSpec {
hostname: Located<String>,
#[confval(range = PORT)]
port: Located<i64>,
#[confval(default = 4, range = WORKERS)]
workers: Located<i64>,
}

An attribute on the wrong leaf, or on a list, a map, or a block, is a compile error.

The attribute records the constraint for the schema, and on a derived spec the derive also runs the check during validation, so the attribute is the single source and the Validate body carries no line for it. A handwritten spec still calls the check itself, because the derive generates nothing for it.

Building and reading a schema

The node types are #[non_exhaustive]. Build a Schema or a SchemaField through Schema::new and SchemaField::new rather than a struct literal. Read a node by its fields, and match a SchemaType, ScalarType, or Constraint with a wildcard arm. Your code then keeps compiling when a release adds a variant or a field.

Handwritten specs

#[derive(Spec)] writes ToSchema for you. A spec you write by hand implements it too, because a derived parent's schema() calls its child's. Build the tree through the same constructors.

use confval::schema::{Constraint, Schema, SchemaField, SchemaType, ScalarType, ToSchema};

impl ToSchema for TlsSpec {
fn schema() -> Schema {
Schema::new(
None,
vec![SchemaField::new(
"mode".to_string(),
None,
true,
false,
SchemaType::Scalar {
leaf: ScalarType::String,
constraint: Some(Constraint::Keywords(&["manual", "acme"])),
},
)],
)
}
}