← Back to site Loading…

rescriptum

Formats and merging

Development

Generated on August 30, 2026

Formats and merging

Formats and merging

src/format/mod.rs gives every document format one interface, so select.rs never has to know which one it is holding.

enum Inner {
    Toml(toml_edit::DocumentMut),
    Yaml(serde_yaml_ng::Value),
    Json(serde_json::Value),
    Xml(xml::Document),
    Text(String),
}

Doc wraps it and offers parse, merge, render, control, strip_control, substitute and has_placeholders. Adding a format means adding a variant and filling in those seven — nothing above this module changes.

Kind

Kind::for_extension is a deliberate allowlist. txt is not on it, so a stray notes file next to the answers never becomes a candidate.

Kind is the family; the extension is kept separately, because they are not the same thing:

  • Kind decides how to parse, how to merge, and the Content-Type.
  • The extension decides whether an endpoint may be answered, and which validator check calls. ks and preseed are both Kind::Text but do not share a validator — which is why Resolution carries format_name alongside format.

Filtering on the family instead of the extension would let a preseed answer /rhel/ks.

endpoint_formats

A small alias table mapping a URL segment to the extensions it accepts. Two traps live in it, both already paid for:

  • Filter on the extension, not the Kind — as above.
  • An alias must be specific enough that nobody reaches it by accident. seed was removed: s=http://server/seed/ is an ordinary NoCloud seed URL, and it serves YAML.

A segment naming no alias constrains nothing, so /answer keeps working.

Merge rules

Maps / objectsmerge recursively
Any other valuereplaced outright by the higher layer
Arraysreplace, they do not append
Kind::Textconcatenation in layer order

Arrays replace because appending would make a list impossible to shorten from a higher layer, and “this node has two disks, not four” has to be expressible. The rule is the same in every format so you never have to remember which one you are in.

merge.rs holds the TOML case, and uses as_table_like so [table] and { inline = "table" } merge with each other — a group can use one style and a machine the other without surprises.

The text case is honest about being concatenation rather than pretending otherwise: whether that amounts to an override is the target format’s business (preseed’s last answer wins; kickstart’s does not always).

The XML tree

format/xml.rs is a small hand-built tree over quick-xml, because none of the general-purpose crates preserve what an answer document needs preserved.

Pairing. Children are paired by element name plus a discriminating attribute:

const DISCRIMINATORS: [&str; 5] = ["name", "id", "key", "alias", "pass"];

That is what makes <component name="Microsoft-Windows-Shell-Setup"> and <settings pass="specialize"> mergeable: overriding one pass leaves the others alone.

Repeated siblings are not always a list. Treating them as one replaced every <component> in an unattend.xml with the one the overlay happened to mention. If they carry a discriminating attribute they are a keyed collection. AutoYaST’s config:type="list" is honoured for the genuine list case.

Fidelity. Declarations, doctypes, namespaces and attributes survive a merge. Original indentation and comment placement do not — the output is re-rendered, not patched.

quick-xml emits entity references as their own events. Ignoring them welds the surrounding text fragments together: 1 &lt; 2 &amp; 3 came back as 123. Numeric entities are resolved; unknown ones are refused rather than silently dropped.

It understands no schema. check calls xmllint where it is installed, and that is the extent of the guarantee.

Control keys

pub const CONTROL_KEYS: [&str; 3] = ["extends", "members", "match"];
pub const XML_CONTROL_ELEMENT: &str = "answer-meta";
pub const TEXT_DIRECTIVE: &str = "answer:";

They travel in whatever the format allows — native top-level keys in the structured formats, an <answer-meta> element in XML, # answer: (or // answer:) directives in text — and strip_control() removes all of them before the answer is sent.

Control is the parsed form: extends: Option<String>, members: Vec<String>, matchers: BTreeMap<String, String>.

Templating

Two rules, both load-bearing:

Substitution happens on parsed string values, never on raw document text. The value goes into the document’s own data model and the format’s serializer writes it out, so the serializer does the escaping. A value containing a quote cannot break the TOML it lands in; one containing < cannot break the XML. A test feeds a"b'c<d>e&f into all four structured formats and reparses the output.

A missing fact is an error, never an empty string. Serving node-.example.com installs a machine with a broken hostname and nobody notices until later. Control characters are refused for the same class of reason — a newline in a kickstart value injects a directive into a file the installer executes.

Group::has_placeholders is why a group with no template costs no parsing per request: the string prepared at load is served as-is.

The worked examples are part of the design

examples/ carries a commented example of all thirteen extensions the allowlist names, and

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check

exercises them all. Keep it that way. They are the only place the formats are shown composing together, and two of them — suse-node.autoyast and windows-node.unattend — are what caught the missing doctype and the unpaired pass.