msgpack_tagged/lib.rs
1//! Tagged-map serialization format for Noir bytecode.
2//!
3//! Design: [issue #12554](https://github.com/noir-lang/noir/issues/12554).
4//!
5//! This crate currently provides:
6//! - The [`MsgpackTagged`] trait — metadata-only, exposing each type's wire
7//! shape via [`Tagged`] plus a hook for building a [`TagRegistry`].
8//! - [`TagRegistry`] / [`Entry`] — the runtime data structure populated by
9//! recursive [`MsgpackTagged::register_into`] calls and consulted by the
10//! wrapper Serializer/Deserializer (added in a follow-up step).
11#![cfg_attr(not(test), warn(unused_crate_dependencies, unused_extern_crates))]
12
13// `msgpack_tagged_derive`'s `MsgpackTagged` proc-macro emits
14// `::msgpack_tagged::...` paths to remain hygienic at every call site. From
15// inside this crate that absolute path doesn't resolve unless we tell rustc
16// the current crate also goes by that name. The alias is only ever used from
17// macro-expanded code, so the lint cannot see the uses.
18#[allow(unused_extern_crates)]
19extern crate self as msgpack_tagged;
20
21mod containers;
22mod primitives;
23mod registry;
24
25pub mod deserializer;
26pub mod serializer;
27
28pub use deserializer::{Deserializer, msgpack_tagged_deserialize};
29pub use serializer::{Serializer, msgpack_tagged_serialize};
30
31pub use msgpack_tagged_derive::MsgpackTagged;
32pub use registry::{
33 Entry, Product, Sum, TagRegistry, Tagged, Variant, VariantKind, type_name_basename,
34};
35
36/// On-wire shape for product types (structs, tuple structs, enum-variant
37/// payloads). Picked per-type on the [`Serializer`] (see
38/// [`Serializer::new`] / [`Serializer::with_strategy`]). Enum variants
39/// are *always* int-keyed under `MsgpackTagged` regardless of strategy —
40/// the strategy only affects struct shape.
41///
42/// * [`EncodingStrategy::Tagged`] (default) — int-keyed `fixmap`
43/// `{0: a, 1: b, …}`. Schema-evolution friendly: identification is by
44/// tag, so fields can be added, removed (via `#[tagged(reserved(...))]`),
45/// or reordered freely. Costs one byte per field for the tag.
46/// * [`EncodingStrategy::Array`] — positional `fixarray` `[a, b, …]`,
47/// fields emitted in tag-ascending order. Minimum overhead. Identification
48/// is by position, so evolvability is limited to *trailing* changes:
49/// - **Adding a trailing field** is backward-compat when the field is
50/// marked `#[serde(default)]` — V1 wire (shorter) decodes into V2
51/// type, the default fills the new position.
52/// - **Removing a trailing field** is forward-compat when the type
53/// opts into `#[tagged(allow_unknown_tags)]` — V2 wire (longer)
54/// decodes into V1 type, the extra trailing position is ignored.
55/// - Anything else (middle insert/remove, reorder, type change) is
56/// wire-breaking. Pick this strategy for small leaf types where size
57/// wins over flexibility and the type is unlikely to need
58/// middle-of-shape edits.
59///
60/// **Auto-downgrade to Tagged.** If a type has `#[tagged(reserved(N))]`
61/// where `N` falls *between* (or before) the active tags, requesting
62/// `Array` for it would corrupt round-trips: V2's positional wire only
63/// carries active values, but the decoder walks a merged-sorted layout
64/// of `(active + reserved)` tags and would drain a wire byte at the
65/// reserved slot intended for a later active field. The encoder detects
66/// this and silently switches to `Tagged` for that product only — other
67/// types in the same serializer keep their configured strategy.
68/// Strictly-trailing reserved tags (every reserved tag greater than
69/// every active tag) keep `Array`: the decoder hits `wire_remaining == 0`
70/// before reaching the trailing reserved slot, so positional alignment
71/// holds. The migration guide in the crate README walks through both
72/// cases with examples.
73///
74/// The decoder probes the wire shape (`fixmap` vs. `fixarray`) per struct
75/// at decode time, so a single buffer can mix both strategies across
76/// nested types freely.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum EncodingStrategy {
79 /// Int-keyed map. Default — most backward/forward compatible.
80 #[default]
81 Tagged,
82 /// Positional array. Smaller; not schema-evolvable.
83 Array,
84}
85
86/// The integer tag used as a wire-level identifier for struct fields and enum
87/// variants. `u8` keeps tags inside msgpack's `fixint` range (0–127) at the
88/// 1-byte-per-tag encoding and rejects `#[tag(N)]` annotations with `N > 255`
89/// at compile time.
90pub type Tag = u8;
91
92/// A type that participates in the tagged-map wire format.
93///
94/// Implementations are typically generated by `#[derive(MsgpackTagged)]` from the
95/// `msgpack_tagged_derive` crate, but can also be hand-written for primitives,
96/// container types, or shadow-DTO public types via `#[tagged(via(WireType))]`.
97///
98/// The trait is metadata-only: it does *not* replace [`serde::Serialize`] /
99/// [`serde::Deserialize`]. It sits alongside them and exposes the type's wire
100/// shape plus a recursive registry-build hook.
101#[diagnostic::on_unimplemented(
102 note = "use `#[derive(MsgpackTagged)]` on the type, or `#[tagged(via(WireType))]` on a shadow-DTO public type that delegates to a wire companion",
103 note = "for container fields, use `BTreeMap` / `BTreeSet` — `HashMap` / `HashSet` are deliberately unsupported on the wire because their iteration order is non-deterministic"
104)]
105pub trait MsgpackTagged: 'static {
106 /// The wire shape of this type — either a [`Product`] (struct/tuple
107 /// struct) or a [`Sum`] (enum). The derive macro emits this from
108 /// `#[tag(N)]` annotations; primitives and container types use a
109 /// `Tagged::Product` with empty `fields`, signaling they don't appear
110 /// directly on the wire as a registry entry but still satisfy the bound.
111 const TAGGED: Tagged;
112
113 /// Recursively register this type and every tagged field type into a registry.
114 ///
115 /// The macro emits the body: it calls `reg.try_insert::<Self>(...)` and, on
116 /// first insert, recurses into each generic and tagged-field type via their
117 /// own `register_into`. Idempotent — re-registering a type is a no-op.
118 fn register_into(reg: &mut TagRegistry);
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 /// Hand-written struct-shaped impl exercising every `Product` field.
126 struct Foo;
127 impl MsgpackTagged for Foo {
128 const TAGGED: Tagged = Tagged::Product(Product {
129 fields: &[(0, "a"), (1, "b")],
130 reserved: &[3],
131 allow_unknown_tags: true,
132 tag_order_matches_source: true,
133 });
134 fn register_into(_reg: &mut TagRegistry) {}
135 }
136
137 /// Minimal impl supplying a `TAGGED` with `Product` extras blank —
138 /// proves the empty shape compiles and reads back as expected.
139 struct Bar;
140 impl MsgpackTagged for Bar {
141 const TAGGED: Tagged = Tagged::empty_product();
142 fn register_into(_reg: &mut TagRegistry) {}
143 }
144
145 #[test]
146 #[allow(clippy::assertions_on_constants)]
147 fn bar_disallows_unknown_by_default() {
148 let p = <Bar as MsgpackTagged>::TAGGED.as_product().unwrap();
149 assert!(!p.allow_unknown_tags);
150 }
151
152 #[test]
153 #[allow(clippy::const_is_empty)]
154 fn bar_has_nothing_reserved() {
155 let p = <Bar as MsgpackTagged>::TAGGED.as_product().unwrap();
156 assert!(p.reserved.is_empty());
157 }
158
159 #[test]
160 fn foo_constants_match_what_was_written() {
161 let p = <Foo as MsgpackTagged>::TAGGED.as_product().unwrap();
162 assert_eq!(p.fields, &[(0, "a"), (1, "b")]);
163 assert_eq!(p.reserved, &[3]);
164 assert!(p.allow_unknown_tags);
165 }
166}