batpak_macros_support/lib.rs
1//! Runtime support types for `#[derive(EventPayload)]`.
2//!
3//! This crate is an implementation detail of the batpak macro surface.
4//! Its public items are re-exported from `batpak::__private`; do not
5//! depend on it directly.
6
7/// Re-exported so generated code can reference `batpak::__private::inventory`
8/// without requiring users to add `inventory` to their own Cargo.toml.
9pub extern crate inventory;
10
11/// Registration item emitted once per `#[derive(EventPayload)]` type.
12///
13/// `inventory::submit!` calls that produce these items are emitted
14/// unconditionally by generated code. That keeps dependency-crate payloads
15/// visible to a downstream binary's explicit registry validator instead of
16/// losing them behind each dependency crate's own `cfg(test)` boundary.
17pub struct EventPayloadRegistration {
18 /// Packed `(category << 12) | type_id`; equals `EventKind::as_raw_u16`
19 /// for the resulting kind.
20 pub kind_bits: u16,
21 /// The derived type's declared `EventPayload::PAYLOAD_VERSION` (>= 1).
22 ///
23 /// Carried here so a binary-wide scan can enumerate `(kind, version)` pairs
24 /// at `Store::open` and confirm a `version > 1` kind has a complete
25 /// registered upcast chain (see [`find_incomplete_upcast_chains`]) instead
26 /// of letting its older events become undecodable at read time.
27 pub payload_version: u16,
28 /// `std::any::type_name::<T>()` for the derived type.
29 pub type_name: &'static str,
30}
31
32inventory::collect!(EventPayloadRegistration);
33
34/// Registration item emitted once per `(KIND, from_version)` upcast step.
35///
36/// Mirrors [`EventPayloadRegistration`]: collected link-time via `inventory`
37/// so the decode seam can look up the registered vN→vN+1 migration for a given
38/// stored kind/version without a runtime registry. The `step` operates on a
39/// type-erased `rmpv::Value` so a single chain runner is lane-neutral (it works
40/// for both the JSON and raw-msgpack replay lanes). The concrete error type is
41/// owned by `batpak` core, so `step` returns a boxed `dyn Error` here to keep
42/// this support crate dependency-light.
43pub struct UpcastRegistration {
44 /// Packed `(category << 12) | type_id`; equals `EventKind::as_raw_u16`.
45 pub kind_bits: u16,
46 /// The stored version this step upgrades *from* (it produces `from + 1`).
47 pub from_version: u16,
48 /// The migration applied to a decoded `rmpv::Value` of version
49 /// `from_version`, producing a value of version `from_version + 1`.
50 pub step: fn(rmpv::Value) -> Result<rmpv::Value, Box<dyn std::error::Error + Send + Sync>>,
51}
52
53inventory::collect!(UpcastRegistration);
54
55/// Return all registered upcast steps for `kind_bits`, indexed by `from_version`.
56///
57/// A duplicate `(kind_bits, from_version)` registration is a programming error
58/// (two migrations claiming the same hop); the caller in `batpak` core surfaces
59/// it as a decode-time failure rather than silently picking one.
60pub fn upcast_steps_for(kind_bits: u16) -> Vec<&'static UpcastRegistration> {
61 inventory::iter::<UpcastRegistration>()
62 .filter(|reg| reg.kind_bits == kind_bits)
63 .collect()
64}
65
66/// A registered payload kind whose declared `PAYLOAD_VERSION > 1` is missing one
67/// or more `from_version` hops in its registered upcast chain.
68///
69/// Such a kind compiles fine, but an event stored at any uncovered version would
70/// be undecodable at read time (the decode seam hits `UpcastError::MissingStep`
71/// with no step to run). Surfacing it lets `Store::open` fail closed up front.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct IncompleteUpcastChain {
74 /// Packed `(category << 12) | type_id`; equals `EventKind::as_raw_u16`.
75 pub kind_bits: u16,
76 /// The declared current payload version (always `> 1` for a reported gap).
77 pub current_version: u16,
78 /// The `from_version` hops in `1..current_version` that have no registered
79 /// step, ascending. Always non-empty for a reported gap.
80 pub missing_from_versions: Vec<u16>,
81 /// A registered type name for `kind_bits`, for diagnostics.
82 pub type_name: &'static str,
83}
84
85/// Return every registered payload kind whose declared version `> 1` lacks a
86/// complete `1 -> ... -> N` upcast chain in the linked registry.
87///
88/// Mirrors [`find_kind_collisions`]: a binary-wide scan over the link-time
89/// `inventory` registries so a downstream binary can refuse to open before any
90/// historical event silently becomes undecodable. The result is sorted by
91/// `kind_bits` for deterministic diagnostics. A kind registered more than once
92/// (a separate collision error) is scored against the highest declared version
93/// so the completeness bar is never understated.
94pub fn find_incomplete_upcast_chains() -> Vec<IncompleteUpcastChain> {
95 let mut declared: std::collections::HashMap<u16, (u16, &'static str)> =
96 std::collections::HashMap::new();
97 for reg in inventory::iter::<EventPayloadRegistration>() {
98 declared
99 .entry(reg.kind_bits)
100 .and_modify(|current| {
101 if reg.payload_version > current.0 {
102 *current = (reg.payload_version, reg.type_name);
103 }
104 })
105 .or_insert((reg.payload_version, reg.type_name));
106 }
107
108 let mut out = Vec::new();
109 for (kind_bits, (current_version, type_name)) in declared {
110 if current_version <= 1 {
111 continue;
112 }
113 let registered: std::collections::HashSet<u16> = inventory::iter::<UpcastRegistration>()
114 .filter(|step| step.kind_bits == kind_bits)
115 .map(|step| step.from_version)
116 .collect();
117 let missing_from_versions: Vec<u16> = (1..current_version)
118 .filter(|hop| !registered.contains(hop))
119 .collect();
120 if !missing_from_versions.is_empty() {
121 out.push(IncompleteUpcastChain {
122 kind_bits,
123 current_version,
124 missing_from_versions,
125 type_name,
126 });
127 }
128 }
129 out.sort_by(|a, b| a.kind_bits.cmp(&b.kind_bits));
130 out
131}
132
133/// One duplicate `EventKind` registration discovered in the current binary.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub struct EventKindCollision {
136 /// Packed `(category << 12) | type_id` representation.
137 pub kind_bits: u16,
138 /// First registered type name for `kind_bits`.
139 pub first_type_name: &'static str,
140 /// Second registered type name for `kind_bits`.
141 pub second_type_name: &'static str,
142}
143
144/// Return all `EventPayload` kind collisions visible in the current binary.
145pub fn find_kind_collisions() -> Vec<EventKindCollision> {
146 let mut seen: std::collections::HashMap<u16, &'static str> = std::collections::HashMap::new();
147 let mut collisions = Vec::new();
148 for item in inventory::iter::<EventPayloadRegistration>() {
149 if let Some(prior) = seen.insert(item.kind_bits, item.type_name) {
150 collisions.push(EventKindCollision {
151 kind_bits: item.kind_bits,
152 first_type_name: prior,
153 second_type_name: item.type_name,
154 });
155 }
156 }
157 collisions
158}
159
160/// Panic if the current binary has duplicate `EventPayload` kind registrations.
161///
162/// # Panics
163///
164/// Panics when more than one registered payload type uses the same
165/// `(category, type_id)` pair in the current binary.
166pub fn assert_no_kind_collisions() {
167 let first = find_kind_collisions().into_iter().next();
168 assert!(
169 first.is_none(),
170 "batpak EventKind collision detected.\n\
171 Types `{prior}` and `{ty}` share the same (category, type_id) \
172 pair (kind_bits = 0x{bits:04X}).\n\
173 Each EventPayload type must have a unique kind within a binary.",
174 prior = first.as_ref().map_or("", |c| c.first_type_name),
175 ty = first.as_ref().map_or("", |c| c.second_type_name),
176 bits = first.as_ref().map_or(0, |c| c.kind_bits),
177 );
178}
179
180/// Scan all `EventPayload` registrations in the current binary for kind collisions.
181///
182/// Called by the `#[test]` function generated by `#[derive(EventPayload)]`.
183/// Detection scope is per-binary: two separate binaries can reuse the same
184/// `(category, type_id)` without triggering a panic here.
185///
186/// Safe to call in non-test binaries. Prefer [`find_kind_collisions`] for a
187/// structured result.
188pub fn scan_for_kind_collisions() {
189 assert_no_kind_collisions();
190}
191
192// ─── Validation ───────────────────────────────────────────────────────────────
193
194/// Maximum valid type_id value (inclusive, 12 bits).
195pub const TYPE_ID_MAX: u16 = 0x0FFF;
196
197/// Validate an EventKind category value.
198///
199/// Valid range: 0x1–0xF, excluding 0xD (reserved for effects).
200///
201/// # Errors
202///
203/// Returns an error when `cat` is zero, reserved for effects, or outside the
204/// four-bit custom category range.
205pub fn validate_category(cat: u8) -> Result<(), &'static str> {
206 match cat {
207 0x0 => Err("category 0x0 is reserved for system events"),
208 0xD => Err("category 0xD is reserved for effect events"),
209 1..=15 => Ok(()),
210 _ => Err("category must fit in 4 bits (0x1–0xF, excluding 0x0 and 0xD)"),
211 }
212}
213
214/// Validate an EventKind type_id value.
215///
216/// Valid range: 0x000–0xFFF (12 bits).
217///
218/// # Errors
219///
220/// Returns an error when `tid` does not fit in the 12-bit type-id range.
221pub fn validate_type_id(tid: u16) -> Result<(), &'static str> {
222 if tid > TYPE_ID_MAX {
223 Err("type_id must fit in 12 bits (0x000–0xFFF)")
224 } else {
225 Ok(())
226 }
227}