kithara derive - zvuk/kithara GitHub Wiki

kithara-derive

Documentation reviewed from PR #536, source revision c0b2e34b3. This records the documented contract at that revision; it is not a runtime or product validation. API and usage · All crates.

Ownership

This crate owns generated configuration declarations, owner bindings, and the shape of a configuration document. Runtime ownership stays with kithara-config and each domain owner. A configuration document's <X>Patch and its merge come from #[derive(Patch)]: a hand-written patch or merge beside a configuration is a defect, not a style choice. This is review policy rather than a lint, since derivable_patch flags only a hand-spelled humantime attribute. The kithara-ui skin overlays and firewheel parameter patches are other types that only share the suffix.

The crate owns no runtime state. Its code depends only on syn, quote, and proc-macro2, plus kithara-workspace-hack for feature unification on non-wasm targets. Expansions name crates by absolute path and re-export nothing, so consumers carry the direct dependency their derives require:

  • Patch names ::serde and ::core. It also names ::humantime_serde for a humantime key and ::kithara_config for a struct that declares check(error = ...).
  • Config and ConfigOwner name ::kithara_config and ::core.
  • Ranged names ::serde and ::core.
  • Event and EventSet name ::kithara_events and ::core.

What The Derive Emits

From a struct with named fields, Patch takes every field not carrying #[patch(skip)] and emits these items in the same module:

  • the patch struct, named after the configuration with a Patch suffix, carrying the configuration's visibility on itself and its fields, deriving Clone, Debug, Default and Deserialize, and attributed #[serde(default, deny_unknown_fields)] and #[non_exhaustive];
  • an inherent impl on the patch holding the deserializer of each non-nested key, which refuses null;
  • an inherent apply on the configuration itself, taking that patch by value and repeating the configuration's own generics and where-clause;
  • for a configuration that declares a refusal (see below), the patch error, named with a PatchError suffix, #[non_exhaustive], implementing Display and core::error::Error, with one variant per way the merge can be refused.

The patch carries no generic parameters of its own. That was the reason the derive was written: struct-patch, the crate it replaced, copies a struct's generics onto the patch it generates. A patch of a generic configuration whose generic-carrying fields are skipped then has a type parameter no field uses and does not compile. The configurations generic over a stream, pool or resampler backend (QueueConfig<S>, AudioConfig<T, B>, the file and HLS configurations) keep those parameters out of every document. The derive has since added null refusal, wire-typed keys, nested and fallible merges, and the validation gate.

Options Inside #[config]

A configuration that derives kithara_config::Config keeps every facet in one #[config(...)] attribute, so Patch also reads the patch(...) group of the type's and of each field's #[config(...)]. An item spells its patch options once: a native #[patch] beside a patch(...) group is refused, because two lists of the same options could disagree. Inside #[config] the patch options are always the group patch(...). Patch sits behind the patch feature, which config implies.

Retained Field Defaults

Config keeps snapshot roles, accessors, construction, debugging, field checks and live changes in one field declaration. fields(...) on a struct and config(...) on an individual field use the same grammar. The struct supplies defaults; a field overrides only the facet it names, and an explicit option group replaces that whole inherited group. An explicit builder or wrapper replaces inherited construction settings. Duplicate declarations at one scope are refused.

value clones a field into an observation snapshot; nested snapshots it through its own Config implementation. Neither operation transfers canonical ownership to the snapshot. get(ref) borrows the original retained field, get(copy) returns it through Copy, and get(skip) disables an inherited accessor. Snapshot exclusion, builder exclusion, patch exclusion, and debug exclusion are independent policies.

The same defaults can carry get(...), builder(...), patch(...), debug(skip), wrap(...), live or live(owner), check = path, and sdk. fields(patch(skip)) excludes document keys by default, and an explicit field patch group replaces that exclusion. Patch supports these patch defaults without requiring the Config derive or its feature. An inherited fields(live) cannot be cancelled per field. live takes no argument except owner, so every field that inherits it must be a value or nested field.

Owner accessors delegate to the retained configuration. A live change passes its own field's check and nothing else. A builder that declares validate_builder passes the configuration's declared check. Accessor generation adds no mutable getter that bypasses either.

Field Checks And Live Changes

PR #536 replaces whole-configuration runtime updates with changes of one field. The owner's control side checks a change, and the executor assigns it.

Field checks. #[config(check(error = E))] on the struct and #[config(check = path)] on a value field (a fn(T) -> Result<T, E>) generate kithara_config::CheckedConfig. Its validated runs the field checks in declaration order and returns the first refusal. It validates every nested field whole through the nested type's own validated, and the nested error converts into E through ?. A field check is refused without the struct's error, and on a nested, skipped or projected field. check(error = ...) excludes patch(validate = ..., error = ...). With it, a document merge gates the merged candidate through validated, and a validate_builder build returns Result<Self, E>.

Live fields. live on a value or nested field makes the field one variant of the generated <Name>Change enum. The enum is Copy and Debug, has the struct's visibility, and names each variant after its field in UpperCamelCase. A value field's variant carries the value. A nested live field's variant carries the nested change, and the parent change implements From for it. The derive implements kithara_config::LiveConfig:

  • check runs the one field's check;
  • apply_change assigns that field alone, with no check or allocation. A real-time executor calls it.

A live configuration must meet these rules:

  • it is Copy and has no generic parameters;
  • it declares check(error = ...) rather than a whole-struct patch(validate = ...), because a change of one field would skip a whole-struct check;
  • it names no field settings or configure;
  • no two fields form the same variant, and no two nested live fields share a type.

Construction inputs cannot declare live fields or checks.

live(owner) marks a value field whose owner executes the change with its own method, as a sample-rate change restarts the output route instead of reaching the render graph. LiveConfig::OWNER_FIELDS reports such a field. A nested live configuration must have none, which a compile-time assertion enforces.

Owner surface. kithara_config::Configure<Ch> is implemented by hand and keyed by the change type, so one owner configures several configurations. It has two methods:

  • configure(&self, change, at) hands a change to the owner for a moment of type At: Default;
  • settings(&self) returns the configuration as last applied.

For a struct with a live field, the derive also emits two traits:

  • <Name>Control, blanket-implemented for every such owner. It has a getter per field with an accessor, which reads settings(), and a set_<field> per live value field, which configures at the default moment. Each nested live field gets a Nested getter. It forwards the nested change to the parent's owner, so the parent's check guards it.
  • <Name>Exec<Cx>, how the owner's executor runs a change. The provided exec routes each live(owner) field to exec_<field>(value, at, cx) and every other live field to exec_live(change, at, cx).

Two fields that generate the same method are refused, and the error names both.

Hygiene. Generated bodies bind __kithara_-prefixed names, so a constant in scope named value, at, cx, change or config never turns a binding into a pattern. Several generated items give Self another meaning: the change enum, <Name>Control, <Name>Exec, <Name>OwnerAccess and the snapshot. In those items, a field type naming Self is respelled as the configuration type with its generics.

Owners. #[config(owner_access)] emits <Name>OwnerAccess for any ConfigOwner of the type. It repeats each get(ref) or get(copy) getter through config(). It is refused without such a getter, and for a field named config, whose getter would collide with ConfigOwner::config. #[derive(ConfigOwner)] takes one of three forms:

  • #[config_owner(field)];
  • #[config_owner(Type, field.path)];
  • #[config_owner(delegate(field))], which reads the configuration through a field that is itself a ConfigOwner. One such field is kithara_command::Live, which owns a live configuration but never hands out &mut to it.

A single #[config_owner_mut] adds ConfigOwnerMut. Its apply_config_change checks a change and applies it only on acceptance. On refusal, the configuration stays unchanged.

Field Mapping

  • A field of type T becomes Option<T>; the merge writes it when the document named it.
  • A field already of type Option<T> stays Option<T>, so a document names the value bare. An absent key is the only way to leave the caller's value standing: a patch cannot clear a field back to None.
  • A key a document names must carry a value. Every non-nested key refuses null through its generated deserializer, rather than reading it as "leave it alone" or as "clear it": the only way to keep a value is to leave the key out. A nested key gets no generated guard. Whether it accepts null depends on how the document format deserializes the nested patch.
  • #[patch(nested)] makes the patch field <T>Patch and the merge recurse. Nesting is declared, not inferred, so a document's shape is readable from the configuration alone.
  • #[patch(wire = <type>, from = <path>)] gives the key a type of its own. Use it when the field holds something a document cannot spell — a live handle, or an enum one of whose variants carries one. The key parses as the wire type and the merge writes #from(value), so the choice a document may make is a type in its own right and the variants it may not name are refused by name rather than dropped in silence. Both halves are required; wire and nested are mutually exclusive, because a replaced type has no nested patch to recurse into.
  • humantime reads a key through humantime_serde. attribute(...) adds attributes to the patch field alone, and a serde(with = ...) or serde(deserialize_with = ...) inside it becomes the key's null-guarded deserializer. A key has one deserializer, which cannot be combined with nested.
  • doc and cfg attributes carry over to the patch field, and a cfg also gates the merge statement, so a feature-gated field stays gated on both sides.

Merges That Can Refuse

Most configurations accept any combination of their own field types, and their apply returns nothing. A configuration whose merge can refuse declares so, and apply returns Result<(), <X>PatchError> instead. A struct declares a refusal in one of three ways:

  • #[patch(validate = <path>, error = <type>)] names a fn(Self) -> Result<Self, E>; either half without the other is refused. The merge builds a whole candidate beside the caller's value, puts it through that gate, and commits only a judged one, so a refused document leaves the caller holding exactly what it had. The refusal reaches the patch error as Invalid. The same gate guards the generated builder when the struct declares validate_builder. A configuration with live fields cannot use it.
  • #[config(check(error = E))] stages the candidate the same way. It puts the candidate through CheckedConfig::validated, so a document merge passes every field check and nested validation. The refusal again arrives as Invalid.
  • #[patch(fallible)] alone, for a struct whose only refusals come from its nested children.

#[patch(nested, fallible)] on a field says the nested configuration judges itself. Its refusal reaches the parent under a variant named after the field, and the error Displays as "<key>: <what the child said>", so a nested refusal reads as the path a document would have to fix. Every fallible child is judged before any key is written, so here too a refused document commits nothing. A refusing merge stages a clone of the configuration or of each fallible child, so those types are Clone.

The signature is the struct's declaration, never a consequence of the fields the current features leave standing. cfg is resolved before a derive runs, so a gated fallible field is invisible here; inferring fallibility from what is left would move apply's signature from build to build. A field marked fallible is therefore refused unless the struct declares a refusal, and fallible requires nested. Consider a struct that declares only patch(fallible) and whose features gate every fallible key out. Its patch error is still emitted, uninhabited, so the callers do not change shape.

Security Contract

A generated patch is Deserialize and never Serialize. By the time a document is typed its $ENV references are resolved, so the patch holds secrets in the clear; serializing one would write them out. The derive emits no Serialize, and adding one to a configuration must not add one to its patch.

Bounded Scalars

#[derive(Ranged)] declares a numeric newtype whose generated doors never produce a value outside its range. The doors are checked, Deserialize, and the opt-in clamping From. A bound therefore crosses a crate boundary as a type rather than as loose constants a caller must remember to clamp against. The derive does not hide the field. The type stays sealed only while its field is private and the declaring module does not write Self(x) itself.

A hand-written MIN/MAX pair with a contains check or a clamping constructor is a defect, and derivable_ranged denies it. Without clamp there is no conversion into the type. clamp is the explicit exception a knob opts into, and it maps NaN to DEFAULT rather than propagating it through every later comparison. A document never clamps: Deserialize refuses an out-of-range value, NaN and infinity, even for a clamping type. Serialize is never emitted. The generated code names ::core and ::serde only, so a crate that declares a Ranged type must carry serde. Standard derives stay at the site.

Events And Consumer Sets

PR #354 adds Event and EventSet. Event emits the marker implementation for a concrete struct or enum and refuses unions, generics, lifetimes, and const parameters. Bounds remain the event trait's contract, checked when the generated implementation is compiled.

EventSet accepts a nonempty concrete enum with exactly one unnamed event field per variant. It emits the member conversions and one receiver per event type. Do not combine it with derive_more::From, which would duplicate those impls. Variant declaration order is receive priority. Async receive skips closed members and returns Closed only when all are closed; nonblocking receive returns lag immediately, keeps scanning empty/closed members, and reports Empty while any member remains open. Publication dispatches the wrapped event with its original metadata. Generated paths require a direct kithara-events dependency.

⚠️ **GitHub.com Fallback** ⚠️