Output format
- Inside of the output directly the tool always produces a
rust/directory (including Cargo.toml, etc). - Unless we pass in
--wasm=falsethe tool also generates a correspondingwasm/directory. - Every crate's generated code lives under a tool-owned
src/generated/subtree (rust/src/generated/mod.rsholds the structs,rust/src/generated/serialization.rstheir (de)serialization implementations/corresponding types, plus the runtime moduleserror.rs/ordered_hash_map.rs; a crate with any@used_as_key-tagged rule also carrieskey_demand_assertions.rs, the private compile-time record of which tag demanded which comparison/hash traits). The crate rootsrc/lib.rsis a thin, user-owned wrapper (mod generated; pub use generated::*;) — see Generated crate roots below. - The
wasm/directory is full of wasm_bindgen-annotated wrappers (underwasm/src/generated/) for the corresponding rust-use-only structs inrust/and can be compiled for WASM builds by runningwasm-pack buildon it. The--target=nodejsoutput runs unchanged under both Node and Bun. Every wasm crate also carries awasm/src/generated/collections.rsindex: onepub use crate::…::<Wrapper>;re-export per collection wrapper class the crate defines (theFooList/MapKToVandNonEmpty…wrappers minted from[* T]/{* K => V}shapes), sorted, with no glob. Because it is compiled as part of the crate, a line naming a removed wrapper fails the crate's own build, so the index can never silently drift from the classes it inventories.
Unused collection-type imports are pruned. A generated file imports the collection helper types (BTreeMap, OrderedHashMap, NonEmptyVec, NonEmptyMap) only when the file — or one of its descendant modules — actually names them: a usage-derived post-pass reads the rendered source and drops any of those four imports the file's module family references nowhere, so a module that uses no maps/non-empty collections carries no unused-import warning for them, independent of what sibling modules use. Descendants count because a child module's use super::*; re-exports the parent module's private imports — the BTreeMap in a scope's mod.rs can be the one its serialization.rs relies on. Only private use items are candidates (pub use re-exports are API surface and never touched). This is scoped to those concrete types by design — trait imports (e.g. std::io::Write, exercised via a method call whose name never mentions Write), macro imports, and glob imports (use ...::*;) cannot be proven unused by name and are left as-is, so a few of those may still be broader than strictly needed.
from_cbor_bytes rejects trailing bytes. The generated/static Deserialize::from_cbor_bytes (and the wasm from_cbor_bytes) decode one complete value and then require the cursor to have consumed the entire input; leftover bytes after a complete value return an error (cbor_event::Error::TrailingData) rather than being silently ignored, so a truncated/corrupt or accidentally-concatenated buffer is caught. (Nested/embedded decoding — e.g. bytes .cbor T — is unaffected; only the top-level entry point enforces this.)
Not every type gets its own (de)serialization in serialization.rs. Some constructs carry their CBOR framing at the point of use rather than as a standalone impl on the type:
- Type aliases — homogeneous arrays (
-> Vec), tables (-> BTreeMap), and plain aliases (foo = bar) are (de)serialized through the aliased type. (Top-level single-type tag rules liketagged = #6.42(text)are NOT aliases — they auto-wrap into a tag-writing/tag-checking newtype; seecurrent_capacities.) - C-style enums — all-fixed-value choices (
foo = 0 / 1 / 2) are encoded inline wherever they are used, not via animplon the enum.
So the tag / fixed-value encoding for these is emitted at each use site. A consequence worth knowing: a type of one of these kinds that is a root rule referenced by nothing else has no standalone (de)serialization, and can only be (de)serialized as part of a containing type.
Item ordering within a file is alphabetical by name, not by kind. Top-level items are emitted sorted name-primary, kind-secondary: alphabetically by the type's bare name (module path and generics ignored), interleaving structs, enums, impls, type aliases, and modules — so a type's struct/enum and its impls sit together, and type Foo is placed alphabetically among everything rather than grouped with the other aliases. Within a single name the struct/enum definition comes first, then its impls. This canonical ordering is stable across unrelated changes to the input, which keeps diffs of generated code small. (It is independent of run-to-run determinism, which holds regardless.)
Non-empty containers
An occurrence lower bound of exactly one — [+ T] and the equivalent [1* T] on arrays, {+ k => v}
/ {1* k => v} on tables — is enforced by the type, not by a bypassable check inside new(). The
field type becomes NonEmptyVec<T> (or NonEmptyMap<K, V>), hand-written generics copied into the
generated crate, so an empty value is simply not representable. A named rule aliases straight to it:
deltas = [+ int] emits pub type Deltas = NonEmptyVec<i64>, giving the rule a real enforcement
surface it never had standalone. Every other bound ([* T], [2*5 T], …) keeps today's bare Vec +
runtime length check — only the exactly-(1, ∞) shape changes representation.
Each non-empty type provides one conversion contract:
TryFrom<Vec<T>>(resp.TryFrom<BTreeMap<K, V>>/TryFrom<OrderedHashMap<K, V>>) — the single checked door. It is the only way to build the value from a loose collection, and it returns aDeserializeErrorwhoseRangeCheck { found: 0, min: Some(1), max: None }displays as0 not at least 1.From<NonEmptyVec<T>> for Vec<T>— the infallible escape hatch. Unwrap to the loose collection, mutate freely, andtry_into()back at the edge. Plusas_slice()/AsRef<[T]>/iter()for read access without a round-trip, and infalliblepush/extend(a push can never break a min-1 bound) with checkedpop/remove(removal that would empty the container errors). Value-level mutable access —iter_mut/as_mut_slice/IndexMutonNonEmptyVec,get_mut/values_mut/iter_mutonNonEmptyMap— is unrestricted: the invariant is about the container's length, which a&mutto an element or value cannot reach, so nested non-empty containers can be updated in place.- Deserialize routes through the same
TryFrom. Wire-side and API-side enforcement cannot drift: an empty CBOR array/map is rejected during decode with the same0 not at least 1error the API raises, so the constraint holds identically at both doors.
Because the invalid state is unrepresentable, a struct whose only rejectable constraint is an
occurrence lower bound gets an infallible new(): every argument is already valid by
construction, and serialization stays infallible (there is no empty value to fail on). Construction
never checks a length — it hands over an already-checked value.
Migrating a consumer (Vec<T> field → NonEmptyVec<T> field). The break is mechanical: the
fallible new(items: Vec<T>) -> Result<_, _> becomes an infallible new(items: NonEmptyVec<T>), and
the length check moves to a single try_into at the point the loose collection is built.
Before (bypassable new check) | After (two-type) |
|---|---|
Foo::new(tags: Vec<Bar>) -> Result<Foo, DeserializeError> | Foo::new(tags: NonEmptyVec<Bar>) -> Foo |
let foo = Foo::new(tags)?; (checks length, but a later foo.tags.clear() re-breaks it) | let tags: NonEmptyVec<Bar> = tags.try_into()?; let foo = Foo::new(tags); |
empty vs. absent modelled as Option<Foo> | empty Vec<Bar> is representable — it just isn't a NonEmptyVec yet; Option returns to meaning "the field itself is optional" |
The type name, the generated doc comment (which quotes the originating CDDL), and the TryFrom
signature are three redundant signals that a constraint exists. The wasm boundary mirrors this with a
two-wrapper pattern — see Wasm Differences.
Generated crate roots (thin root, seed-once)
Every generated crate — rust/, and (when enabled) wasm/ and wasm/json-gen/ — splits into two
halves so your hand edits to the crate root survive regeneration:
-
Tool-owned:
src/generated/**(clobbered every run). All generated code lives here —generated/mod.rs(the type definitions + module decls + generator crate attrs like#![allow(clippy::too_many_arguments)]as inner attrs),generated/serialization.rs, per-scope submodules, the copied runtime modules (error.rs,ordered_hash_map.rs), and — when any rule carries@used_as_key—key_demand_assertions.rs. (Under--common-import-overridethe runtime modules are not emitted — the override crate owns its copy, and--export-static-diris the supported path for keeping that copy current.) This subtree is overwritten wholesale on every export — untagged hand edits to the code are clobbered. What DOES survive regeneration: own-line comments (carried automatically by symbol identity — which named item they sit in/above) and tagged code blocks —// cddl-codegen:insert-start/insert-endfor added lines, and// cddl-codegen:replace-start/replaces/replace-endfor swapped code (your version plus a//-commented record of the generated code it overrides). Anything that cannot be safely re-placed becomes acompile_error!block (loud, never a silent drop) so you review it. See preserving edits for the full syntax, failure modes, and limits; pass--no-preserve-commentsto turn the whole overlay off and clobber pristine. Two carve-outs: trailing (end-of-line) comments are not carried — move them to their own line — and///doc text on tool-documented items is tool-owned (it flows from your CDDL/@doc; hand edits to it are dropped, so edit the CDDL instead). -
User-owned: the crate root
src/lib.rs(seeded once, then never touched). On a first export the tool seeds a thin root:// Seeded by cddl-codegen on first export; never overwritten after that.// All regenerated code lives in the `generated` module. Add your own// modules/re-exports/attrs here freely (e.g. `pub mod utils;`).mod generated;pub use generated::*;If
src/lib.rsalready exists, the tool leaves it byte-for-byte untouched — an existence check only, never a read of its contents to decide what to emit (the same bounded exception theCargo.tomlchangeset carves out of the tool's otherwise strict no-prior-output-dependence). New and renamed generated types surface automatically through thepub use generated::*;glob, so the root needs no per-type maintenance.
This is what makes extern types and other hand wiring durable. An extern type
(MyExt = _CDDL_CODEGEN_EXTERN_TYPE_) requires you to supply its Rust definition. Generated code
refers to it by name inside src/generated/** (e.g. a field ext: MyExt, and MyExt::deserialize
in generated/serialization.rs), but those names can't see a type you define in the crate root — a
parent-module name is not visible inside mod generated. So the tool emits re-export glue into the
declaring scope's generated module:
// src/generated/mod.rs (tool-owned) — resolves every in-crate `MyExt` reference back to your definition
pub use crate::MyExt;
Your job is to make crate::MyExt resolve: define the extern in a hand-written module and
re-export it at the crate root, from the user-owned thin root (this is the same wiring pre-split
consumers already used — it just moved to the thin lib.rs):
mod generated;
pub use generated::*;
pub mod utils; // your hand-written module
pub use utils::MyExt; // re-export it at the crate root so `crate::MyExt` resolves
// src/utils.rs — supply the definition + the (de)serialization impls the generated code calls.
// Everything the impls need is reachable at the crate root through the seeded `pub use generated::*;`.
use crate::error::DeserializeError;
use crate::serialization::Deserialize;
#[derive(Clone, Debug)]
pub struct MyExt(pub u64);
impl cbor_event::se::Serialize for MyExt {
fn serialize<'se, W: std::io::Write>(
&self,
serializer: &'se mut cbor_event::se::Serializer<W>,
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer<W>> {
serializer.write_unsigned_integer(self.0)
}
}
impl Deserialize for MyExt {
fn deserialize<R: std::io::BufRead + std::io::Seek>(
raw: &mut cbor_event::de::Deserializer<R>,
) -> Result<Self, DeserializeError> {
Ok(Self(raw.unsigned_integer()?))
}
}
An extern declared inside a scope a gets its glue in src/generated/a/mod.rs instead (still
pub use crate::MyExt; — the re-export always resolves through your crate-root re-export). If you
forget to re-export the type at the crate root, the glue fails loudly with a clear
unresolved-import error naming the type, rather than a confusing "cannot find type" deep in the
generated code.
The wasm crate gets the same glue. Its generated code names the extern's hand-written
#[wasm_bindgen] wrapper (e.g. pub fn ext(&self) -> MyExt) by bare ident inside
wasm/src/generated/**, invisible there for the same reason. So the tool emits pub use crate::MyExt;
into the wasm declaring scope's generated module too, and the contract is identical: define the wasm
wrapper in a hand-written wasm-crate module and re-export it at the wasm crate root (pub mod utils; pub use utils::MyExt; in wasm/src/lib.rs, with the wrapper supplying the From/AsRef conversions
the generated boundary code calls).
Because the root is user-owned, the tool never re-clobbers this wiring — the failure mode where a
regeneration dropped a hand-added pub mod utils; and broke every reference to the extern type is
gone by construction.
Migrating from pre-split layouts
Crates generated before the thin-root split have a monolithic src/lib.rs that carries the
generated type definitions and module decls (pub mod serialization;, …) inline. Because the tool now
seed-once preserves any existing src/lib.rs, it will not migrate that root for you (auto-migrating
would mean reading and rewriting your file — the prior-output dependence the tool forbids). Instead the
stale root breaks loudly at compile time: the generated types reappear under src/generated/** and
the old pub mod serialization; decls now point at files the split relocated (an E0583 unresolved-
module error), and any inline type copies collide with the regenerated ones.
The tool prints a one-time stderr warning when it sees a root without mod generated;. The one-time fix
(per crate root — rust/, wasm/, wasm/json-gen/):
- Delete the generated items from
src/lib.rs— the type definitions and thepub mod serialization;/pub mod cbor_encodings;/ runtime-module decls the tool now owns undergenerated/. - Keep your hand wiring — your own
pub mod/pub use/crate attrs, extern-type modules, etc. - Add the two thin-root lines:
mod generated;andpub use generated::*;. - Regenerate. From here on the root is yours and survives every run.
Workspace mode: borrowed and requested collection wrappers
When a set of co-generated crates form a workspace — one crate per CDDL spec, later crates importing
earlier ones as extern deps — a collection wrapper over another crate's types
([* foo] → FooList, {* k => v} → MapKToV, and their NonEmpty… variants) must live in
exactly one crate. Two crates that each mint the same #[wasm_bindgen] FooList export one JS
class name twice and fail to link a single wasm cdylib (duplicate symbol: __wbg_foolist_free). The
--workspace-dep (consumer side) and --wrapper-requests (dependency side) flags place every
all-one-dep wrapper in the owning dependency, so every consumer imports one shared definition. Two
generated files carry this, both under the wasm crate's tool-owned src/generated/** (clobbered
every run like the rest of that subtree):
borrowed_collections.rs — the consumer's sidecar
Emitted whenever --workspace-dep is set (empty-but-present when nothing is borrowed — stable
presence, stable diffs). It is the mirror of the collections.rs wrapper index — "what I borrow, from whom" against
that file's "what I provide" — and its format is frozen, because the owning dependency machine-reads
it:
// This file records every collection wrapper this crate borrows from workspace deps.
// It is machine-read by those deps' generation runs (--wrapper-requests) and compiled
// here, so a wrapper a dep stops providing fails THIS crate's build, naming the type.
// Rows are (dep rust-crate name, wrapper name, shape in CDDL syntax with the dep's idents).
#[allow(unused_imports)]
mod borrowed {
use cml_dep_wasm::collections::FooList;
use cml_dep_wasm::collections::MapKToV;
}
#[allow(dead_code)]
pub(crate) const BORROWED_SHAPES: &[(&str, &str, &str)] = &[
("cml_dep", "FooList", "[* foo]"),
("cml_dep", "MapKToV", "{* k => v}"),
];
The two halves serve different validators. The private mod borrowed of use lines is the
compile-checked half: it names every borrowed wrapper, so a wrapper the dependency stops providing
fails this crate's own build with an unresolved import naming the exact type. The
BORROWED_SHAPES table is the machine half the dependency parses: (dependency rust-crate name, wrapper name, shape in CDDL syntax with the dependency's idents), sorted by (dep, name). The
shape column is authoritative — the dependency derives the wrapper's definition from the shape and
only cross-checks the name (reverse-parsing element types out of a name like MapAToBToC is
ambiguous). The name and shape agree across the two crates only when the consumer's extern stub is
representation-faithful to the dependency's spec — the stub-fidelity contract documented under
--wrapper-requests; an unfaithful stub surfaces as a
dependency-side hard error naming the element and the stub fix. The file is derived output, rebuilt from the consumer's spec on every regen; borrowed
wrappers are never re-exported (they are not the consumer's public API). All comments — the column
legend included — live in the fixed banner, never inside the const body, so the edit-preservation
overlay anchors them to the file itself and a regen that adds or removes rows never has a comment
stranded on a deleted row.
A user may hand-add a row via the overlay's // cddl-codegen:insert-start/end (or replace) blocks —
honored like any preserved edit when the payload conforms to the fixed row format. A
compile_error! / cddl-codegen:unpreserved-comment trap block, an unknown line, or a mangled row is
not silently tolerated: the dependency's strict parser rejects the whole sidecar as a hard error
naming the file, on the principle that a cross-crate request channel must never consume a drifted
generated file. Regenerate the consumer crate to clear such a trap before regenerating the dependency.
borrowed_key_types.rs — the consumer's map-key-derive sidecar
Emitted into the rust crate (rust/src/generated/borrowed_key_types.rs) whenever
--workspace-dep is set (same empty-but-present semantics as borrowed_collections.rs). Where the
wasm-side sidecar records borrowed wrapper classes, this one records borrowed map-key types:
a consumer map keyed on a dependency's type needs Eq/Ord/PartialOrd (plus Hash under
--preserve-encodings) derived on that type, and the orphan rule means only the dependency's crate
can provide the derives. Same two-half structure: a compiled
_borrowed_key_types_self_check fn (a dependency dropping a derive fails this crate's build,
naming the type) and the machine-read BORROWED_KEY_TYPES table of
(dep rust-crate name, cddl ident) rows, sorted, which the dependency re-reads via
--key-requests and seeds into its key-derive computation before finalize.
Strictly parsed under the same never-consume-a-drifted-file principle as BORROWED_SHAPES.
// This file records every map-key type this crate borrows from workspace deps.
// …
#[allow(dead_code)]
fn _assert_key_traits<K: Eq + Ord + PartialOrd + core::hash::Hash>() {}
#[allow(dead_code)]
fn _borrowed_key_types_self_check() {
_assert_key_traits::<cml_dep::Foo>();
}
#[allow(dead_code)]
pub(crate) const BORROWED_KEY_TYPES: &[(&str, &str)] = &[("cml_dep", "foo")];
When a borrowed key carries a @used_as_key flavor (hash/ord) rather than the
bare full bundle, each row gains an optional third column naming the flavor, the table type becomes
&[(&str, &str, &str)], and the self-check splits into per-flavor bound carriers
(_assert_key_traits_hash<K: Eq + core::hash::Hash>(), …) so a hash-only borrow is not checked against
the full Ord bundle. An all-bare sidecar keeps the two-column form byte-identically — this is a
pure superset. The three-column banner is a declared breaking seam: a cddl-codegen predating flavor
support hard-errors ("unexpected comment"/"row must be … two string literals") when it re-reads a new
consumer's flavored sidecar, rather than silently mis-deriving. Keep workspace tool versions in sync.
requested_collections.rs — the dependency's hosted wrappers
Emitted whenever --wrapper-requests is set (empty-but-present when flagged, absent with no flag —
flag-off output is byte-identical to before). The dependency unions the requested shapes across all
consumers' sidecars, and emits every requested wrapper it does not already produce, from its own
module view (correct crate::… paths, current tool version, one writer per file). Each wrapper carries
a tool-owned attribution doc listing its requesters alphabetically:
/// Generated at the request of: cml_chain, cml_multiera.
#[derive(Clone, Debug)]
#[wasm_bindgen]
pub struct FooList(Vec<cml_dep::Foo>);
Attribution is a /// doc comment — the overlay's tool-owned documentation class, which regenerates
freely — so adding or dropping a requester churns the doc in place without ever tripping edit
preservation. Requested wrappers are indexed in the dependency's own collections.rs
(pub use crate::generated::requested_collections::<Name>;) exactly like a wrapper the dependency's
own spec produces, so a consumer's use <dep_wasm>::collections::<Name>; resolves. A requested
NonEmpty… wrapper additionally provisions the NonEmptyVec/NonEmptyMap runtime and may emit its
loose try_from source wrapper as an (unattributed) transitive support type.
The regeneration contract
- Holistic regen runs in reverse dependency order, one pass. Consumers first — each rewrites its own sidecar and reads nothing from any dependency's output — then dependencies, each reading its consumers' committed sidecars. A crate that is both consumer and dependency does both jobs in its single run. An unchanged workspace regenerates to a zero diff, and regenerating any single crate twice is byte-identical (attribution and shape payload included).
- Consumer-alone regen is safe unless it adds a borrow. A spec change that introduces no new borrowed wrapper (the common iterate-on-my-own-crate loop) stays green with no dependency regen. A change that adds a borrow the dependency does not yet host fails the consumer's build with a loud unresolved import naming that wrapper — fixed by regenerating the dependency. This is a subset state (a consumer needs a wrapper the dependency lacks) and cannot exist silently.
- Dep-alone regen is always safe. It re-reads the committed sidecars, so a converged workspace reproduces byte-identical output — the per-crate idempotency guarantee holds with no asterisk.
- Removals are a benign superset. When the last borrowing consumer drops a shape, its regen shrinks its sidecar; the next dependency regen shrinks the union and deletes the wrapper, its index line, and its attribution doc — an ordinary code diff. Between the two regens the dependency carries an extra unused wrapper, which is harmless.
Generated Cargo.toml (merge, not clobber)
The generated manifests — rust/Cargo.toml, and (when enabled) wasm/Cargo.toml and
wasm/json-gen/Cargo.toml — are not overwritten wholesale on regeneration. Each run the tool
applies a declarative changeset to whatever manifest already exists on disk (or to an empty document
on a first run), so hand edits you make outside the keys the tool owns survive verbatim — comments,
blank lines, formatting, key ordering, [profile.*] sections, and any dependencies you added are all
preserved byte-for-byte.
Every key falls into one of three categories:
- Tool-owned (rewritten every run).
package.name,package.edition,lib.crate-type, and the entire[dependencies]set the generator emits (e.g.cbor_event, and the flag/type-conditional depslinked-hash-map,derivative,serde,serde_json,schemars,hex,wasm-bindgen,serde-wasm-bindgen). Non-dependency keys here are rewritten wholesale — your edits to them are overwritten by design (the same contractcargo addmakes). Dependency entries are merged field-level, not clobbered: the tool owns the version floor (a compatible pin you wrote is kept verbatim — caret semantics — while an incompatible one is bumped up to the tool's requirement), the features it requires (unioned into your list, order preserved), and any field it sets (e.g. thepathon the wasm crate's path dep). Everything else on the entry —optional,default-features, extra features, aversionyou added beside ourpathfor publishing — is preserved, and the entry keeps its existing shape (a plain-string dep stays a plain string unless a merged field forces a table). This means, e.g.,wasm-bindgen = { version = "0.2.126", optional = true }survives regeneration with itsoptionalintact instead of collapsing to"0.2". A conditional dep is emitted when its flag/type condition holds and removed when it doesn't, so flipping a flag off (e.g. dropping--preserve-encodings) also drops the deps it pulled in, rather than stranding them — note the removal deletes the whole entry, including any fields you added to it. For the same reason, regenerate with the same flags every time: a regen that accidentally omits a flag (e.g. forgetting--wasm) reads as "condition off" and removes that flag's conditional deps from your manifest. Pin the full command in a script or Makefile. - Seeded once.
package.versionis written only if it's absent. After the initial0.1.0(0.0.1for the json-gen crate) the tool has no opinion — bump it and it stays bumped across regenerations. - Preserved (never touched). Any key no changeset op mentions: your own dependencies,
[profile]/[features]tables, workspace inheritance lines, etc.
The tool also writes a package.metadata.cddl-codegen.generated-with stamp recording the
cddl-codegen version. It is write-only — the tool never reads it back (reading prior output would
make generation depend on disk contents), so it's purely informational for debugging/support.
If an existing manifest is not valid TOML, regeneration is a hard error naming the file — the tool never falls back to clobbering, because a parse failure is exactly the case where it can't safely tell your content apart from its own.
Known limitation: changing --lib-name between runs leaves the old-name path dep behind in
wasm/Cargo.toml (the changeset is keyed by the current name). The error is loud (a duplicate/
unused dep) and the fix is to delete the stale line by hand.
Example Output

The output format can change slightly depending on certain command line flags:
--wasm=false

--preserve-encodings=true

--json-schema-export true

--package-json true --json-schema-export true
