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}