Skip to main content

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}