Skip to main content

Output format

  • Inside of the output directly the tool always produces a rust/ directory (including Cargo.toml, etc).
  • Unless we pass in --wasm=false the tool also generates a corresponding wasm/ directory.
  • Every crate's generated code lives under a tool-owned src/generated/ subtree (rust/src/generated/mod.rs holds the structs, rust/src/generated/serialization.rs their (de)serialization implementations/corresponding types, plus the runtime modules error.rs/ordered_hash_map.rs; a crate with any @used_as_key-tagged rule also carries key_demand_assertions.rs, the private compile-time record of which tag demanded which comparison/hash traits). The crate root src/lib.rs is 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 (under wasm/src/generated/) for the corresponding rust-use-only structs in rust/ and can be compiled for WASM builds by running wasm-pack build on it. The --target=nodejs output runs unchanged under both Node and Bun. Every wasm crate also carries a wasm/src/generated/collections.rs index: one pub use crate::…::<Wrapper>; re-export per collection wrapper class the crate defines (the FooList / MapKToV and NonEmpty… 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 like tagged = #6.42(text) are NOT aliases — they auto-wrap into a tag-writing/tag-checking newtype; see current_capacities.)
  • C-style enums — all-fixed-value choices (foo = 0 / 1 / 2) are encoded inline wherever they are used, not via an impl on 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 a DeserializeError whose RangeCheck { found: 0, min: Some(1), max: None } displays as 0 not at least 1.
  • From<NonEmptyVec<T>> for Vec<T> — the infallible escape hatch. Unwrap to the loose collection, mutate freely, and try_into() back at the edge. Plus as_slice() / AsRef<[T]> / iter() for read access without a round-trip, and infallible push/extend (a push can never break a min-1 bound) with checked pop/remove (removal that would empty the container errors). Value-level mutable access — iter_mut/as_mut_slice/IndexMut on NonEmptyVec, get_mut/values_mut/ iter_mut on NonEmptyMap — is unrestricted: the invariant is about the container's length, which a &mut to 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 same 0 not at least 1 error 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_keykey_demand_assertions.rs. (Under --common-import-override the runtime modules are not emitted — the override crate owns its copy, and --export-static-dir is 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-end for added lines, and // cddl-codegen:replace-start/replaces/replace-end for swapped code (your version plus a //-commented record of the generated code it overrides). Anything that cannot be safely re-placed becomes a compile_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-comments to 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.rs already 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 the Cargo.toml changeset carves out of the tool's otherwise strict no-prior-output-dependence). New and renamed generated types surface automatically through the pub 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/):

  1. Delete the generated items from src/lib.rs — the type definitions and the pub mod serialization; / pub mod cbor_encodings; / runtime-module decls the tool now owns under generated/.
  2. Keep your hand wiring — your own pub mod/pub use/crate attrs, extern-type modules, etc.
  3. Add the two thin-root lines: mod generated; and pub use generated::*;.
  4. 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 deps linked-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 contract cargo add makes). 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. the path on the wasm crate's path dep). Everything else on the entry — optional, default-features, extra features, a version you added beside our path for 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 its optional intact 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.version is written only if it's absent. After the initial 0.1.0 (0.0.1 for 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

note

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