Skip to main content

ix_schema/
lib.rs

1//! # ix-schema — a universal meta-interface for data structures
2//!
3//! `ix-schema` does not serialize anything itself. It is an **orchestrator**: a
4//! `#[derive(Ix)]` type publishes a compile-time [`Manifest`] describing its
5//! fields, memory layout and schema evolution. *Drivers* (adapters around
6//! `serde`, `zerocopy`, …) read that manifest and do the real work, branching on
7//! `const` data that folds away — no reflection, no runtime schema parsing.
8//!
9//! The crate is `#![no_std]` for normal builds; everything the manifest carries
10//! is `const` and therefore free at runtime.
11//!
12//! ## The two manifests
13//!
14//! There are two strictly separate representations:
15//!
16//! * the **compile-time IR** that lives only inside the `ix-schema-derive` proc-macro
17//!   while it analyses a struct, and
18//! * the **`const` [`Manifest`]** emitted into your crate, read by drivers.
19//!
20//! This crate defines the second one — the contract every driver speaks.
21#![cfg_attr(not(test), no_std)]
22#![forbid(unsafe_code)]
23#![warn(missing_docs)]
24#![deny(rustdoc::broken_intra_doc_links, rustdoc::private_intra_doc_links)]
25
26/// Derive a compile-time [`Manifest`] (and, with `migrate_from`, a type-safe
27/// migration edge) for a struct or enum. See the crate-level docs for the
28/// attributes.
29pub use ix_schema_derive::Ix;
30
31/// The compile-time description of a `#[derive(Ix)]` type.
32///
33/// Every field is `const`-foldable. Layout figures (size, align, offsets) are
34/// produced by the compiler via [`core::mem`] in const context, so the manifest
35/// is *guaranteed* to match the real in-memory layout — it is computed by the
36/// same compiler that lays out the struct.
37pub trait Ix {
38    /// The semantic manifest for this type and schema version.
39    const MANIFEST: Manifest<'static>;
40}
41
42/// A type-safe, compile-time migration edge from an older schema version `Prev`.
43///
44/// Modelled after [`From`]: `V2: MigrateFrom<V1>` declares that a `V1` value can
45/// be lifted to `V2`. The derive macro generates the body field-by-field, so an
46/// impossible transformation becomes a *type error*, not a runtime panic.
47pub trait MigrateFrom<Prev: Ix>: Ix + Sized {
48    /// Lift a value of the previous schema version into this one.
49    fn migrate_from(prev: Prev) -> Self;
50}
51
52/// A multi-hop migration from an older schema `Self` to a later one `Target`.
53///
54/// Where [`MigrateFrom`] is a single edge (`V2` from `V1`), `Upgrade` is a whole
55/// path (`V1` all the way to `V3`). Implementations are generated by
56/// [`migrate_chain!`] as the composition of single-hop [`MigrateFrom`] calls, so
57/// every hop is type-checked and the whole walk inlines to zero runtime cost.
58pub trait Upgrade<Target> {
59    /// Convert this value to `Target` by composing single-hop migrations.
60    fn upgrade(self) -> Target;
61}
62
63/// Generate the transitive closure of [`Upgrade`] impls for a schema chain.
64///
65/// Given adjacent [`MigrateFrom`] edges (typically from `#[derive(Ix)]` with
66/// `migrate_from`), this wires up *every* older-to-newer pair by composition —
67/// most importantly the direct jump from the oldest version to the newest.
68///
69/// ```text
70/// // V2: MigrateFrom<V1>, V3: MigrateFrom<V2> already exist (derived).
71/// ix_schema::migrate_chain!(V1 => V2 => V3);
72/// // now: V1: Upgrade<V2>, V2: Upgrade<V3>, and V1: Upgrade<V3> (composed).
73/// let v3: V3 = some_v1.upgrade();
74/// ```
75#[macro_export]
76macro_rules! migrate_chain {
77    // Public entry: a chain of at least two versions.
78    ($first:ty $(=> $rest:ty)+) => {
79        $crate::migrate_chain!(@grow [$first] $(=> $rest)+);
80    };
81
82    // Inductive step: append `$new`, wiring up every seen type to it. `$pred` is
83    // the immediately-previous version; `$older` are the earlier ancestors.
84    (@grow [$pred:ty $(, $older:ty)*] => $new:ty $(=> $rest:ty)*) => {
85        impl $crate::Upgrade<$new> for $pred {
86            fn upgrade(self) -> $new {
87                <$new as $crate::MigrateFrom<$pred>>::migrate_from(self)
88            }
89        }
90        $(
91            impl $crate::Upgrade<$new> for $older {
92                fn upgrade(self) -> $new {
93                    <$new as $crate::MigrateFrom<$pred>>::migrate_from(
94                        <$older as $crate::Upgrade<$pred>>::upgrade(self),
95                    )
96                }
97            }
98        )*
99        $crate::migrate_chain!(@grow [$new, $pred $(, $older)*] $(=> $rest)*);
100    };
101
102    // Base case: the chain is fully consumed.
103    (@grow [$($seen:ty),+]) => {};
104}
105
106/// A driver adapts an external representation (serde, zerocopy, …) to any
107/// [`Ix`] type by reading its [`Manifest`].
108///
109/// All capability decisions are `const`: code that branches on
110/// [`Driver::SUPPORTED`] is dead-code-eliminated, keeping drivers zero-cost.
111pub trait Driver<T: Ix> {
112    /// Whether this driver can losslessly handle `T`'s layout and schema.
113    const SUPPORTED: bool;
114}
115
116/// The semantic manifest: the single source of truth drivers read.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct Manifest<'a> {
119    /// Fully spelled type name, e.g. `"my_crate::User"`.
120    pub type_name: &'a str,
121    /// Monotonic schema version (`1` for the first revision).
122    pub schema_version: u32,
123    /// In-memory layout of the type.
124    pub layout: LayoutSpec,
125    /// Fields in declaration order (for a struct; empty for an enum).
126    pub fields: &'a [FieldSpec<'a>],
127    /// Variants in declaration order (for a fieldless enum; empty for a struct).
128    pub variants: &'a [VariantSpec<'a>],
129    /// How this version relates to its predecessor.
130    pub evolution: EvolutionSpec<'a>,
131}
132
133impl Manifest<'_> {
134    /// Sum of the sizes of all fields, ignoring inter-field padding.
135    #[must_use]
136    pub const fn packed_field_bytes(&self) -> usize {
137        let mut sum = 0;
138        let mut i = 0;
139        while i < self.fields.len() {
140            sum += self.fields[i].size;
141            i += 1;
142        }
143        sum
144    }
145
146    /// Bytes of padding the compiler inserted between/after fields.
147    ///
148    /// A return value of `0` means the layout is gap-free — the precondition a
149    /// zerocopy driver checks before treating the type as plain bytes.
150    #[must_use]
151    pub const fn padding_bytes(&self) -> usize {
152        self.layout.size.saturating_sub(self.packed_field_bytes())
153    }
154
155    /// Whether the layout has no padding holes (gap-free).
156    #[must_use]
157    pub const fn is_gap_free(&self) -> bool {
158        self.padding_bytes() == 0
159    }
160
161    /// Whether `self` is a layout-compatible, append-only extension of `prev`:
162    /// every field of `prev` is still present, at the same offset, size and type.
163    ///
164    /// This is the compile-time precondition for evolving a zerocopy type while
165    /// keeping old readers valid — new fields may only be appended, never
166    /// inserted, reordered, resized or retyped. The type check matters: a field
167    /// silently changed from `u32` to `f32` keeps the same offset and size but
168    /// reinterprets the bytes, so it must not pass. Pair with [`assert_compatible!`].
169    #[must_use]
170    pub const fn extends(&self, prev: &Manifest<'_>) -> bool {
171        let mut i = 0;
172        while i < prev.fields.len() {
173            let want = prev.fields[i];
174            let mut matched = false;
175            let mut j = 0;
176            while j < self.fields.len() {
177                let have = self.fields[j];
178                if str_eq(have.name, want.name) {
179                    matched = have.offset == want.offset
180                        && have.size == want.size
181                        && str_eq(have.type_name, want.type_name);
182                    break;
183                }
184                j += 1;
185            }
186            if !matched {
187                return false;
188            }
189            i += 1;
190        }
191        true
192    }
193}
194
195/// `const`-evaluable string equality (`PartialEq` for `str` is not `const`).
196const fn str_eq(a: &str, b: &str) -> bool {
197    let a = a.as_bytes();
198    let b = b.as_bytes();
199    if a.len() != b.len() {
200        return false;
201    }
202    let mut i = 0;
203    while i < a.len() {
204        if a[i] != b[i] {
205            return false;
206        }
207        i += 1;
208    }
209    true
210}
211
212/// Statically assert that `$new` is a layout-compatible, append-only extension
213/// of `$old` (see [`Manifest::extends`]). Fails compilation if a carried field
214/// was moved, resized or dropped — catching wire-format breakage before runtime.
215///
216/// ```text
217/// ix_schema::assert_compatible!(EventV2 : EventV1);
218/// ```
219#[macro_export]
220macro_rules! assert_compatible {
221    ($new:ty : $old:ty) => {
222        const _: () = ::core::assert!(
223            <$new as $crate::Ix>::MANIFEST.extends(&<$old as $crate::Ix>::MANIFEST),
224            ::core::concat!(
225                stringify!($new),
226                " is not a layout-compatible extension of ",
227                stringify!($old),
228            ),
229        );
230    };
231}
232
233/// In-memory layout of a type.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct LayoutSpec {
236    /// `size_of::<T>()`.
237    pub size: usize,
238    /// `align_of::<T>()`.
239    pub align: usize,
240    /// The declared representation.
241    pub repr: Repr,
242}
243
244/// The `#[repr(..)]` of a type, as far as it affects layout stability.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum Repr {
247    /// Default Rust representation (layout not guaranteed stable).
248    Rust,
249    /// `#[repr(C)]` — stable, FFI-compatible layout.
250    C,
251    /// `#[repr(transparent)]`.
252    Transparent,
253    /// `#[repr(packed(n))]` with the given alignment.
254    Packed(usize),
255}
256
257/// Description of a single field within a [`Manifest`].
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct FieldSpec<'a> {
260    /// Field identifier.
261    pub name: &'a str,
262    /// Spelled type, e.g. `"u32"`.
263    pub type_name: &'a str,
264    /// Byte offset within the struct (`offset_of!`).
265    pub offset: usize,
266    /// `size_of` of the field type.
267    pub size: usize,
268    /// `align_of` of the field type.
269    pub align: usize,
270    /// Schema version in which the field was introduced.
271    pub since: u32,
272}
273
274/// How an enum variant carries its payload.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum VariantKind {
277    /// No payload — `Variant`.
278    Unit,
279    /// Positional payload — `Variant(A, B)`.
280    Tuple,
281    /// Named payload — `Variant { a: A }`.
282    Struct,
283}
284
285/// Description of a single payload field of a data-carrying enum variant.
286///
287/// Note the deliberate absence of an `offset`: Rust exposes no `const` way to
288/// read the byte offset of a field *within an enum variant* (`offset_of!` covers
289/// structs only). Rather than record a number it cannot verify — which would
290/// break the manifest's "computed by the compiler, cannot drift" guarantee — ix
291/// records only what it can prove: the field's name, spelled type, size and
292/// alignment.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct VariantFieldSpec<'a> {
295    /// Field name for a struct-like variant; `None` for a tuple position.
296    pub name: Option<&'a str>,
297    /// Spelled type, e.g. `"u32"`.
298    pub type_name: &'a str,
299    /// `size_of` of the field type.
300    pub size: usize,
301    /// `align_of` of the field type.
302    pub align: usize,
303}
304
305/// Description of a single enum variant.
306///
307/// A fieldless variant is fully described by its name and discriminant; a
308/// data-carrying variant additionally lists its payload [`VariantFieldSpec`]s.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct VariantSpec<'a> {
311    /// Variant identifier.
312    pub name: &'a str,
313    /// The variant's integer discriminant, when the compiler can evaluate it in
314    /// `const`: `Some` for a fieldless enum (emitted as `Self::Variant as i64`),
315    /// `None` for a data-carrying enum, whose variants cannot be cast to an int.
316    pub discriminant: Option<i64>,
317    /// Whether the variant is unit, tuple, or struct shaped.
318    pub kind: VariantKind,
319    /// The variant's payload fields (empty for a unit variant). Offsets are
320    /// intentionally absent — see [`VariantFieldSpec`].
321    pub fields: &'a [VariantFieldSpec<'a>],
322}
323
324/// How a schema version relates to the one before it.
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct EvolutionSpec<'a> {
327    /// The version this one migrates from, or `None` for the genesis version.
328    pub migrates_from: Option<u32>,
329    /// Field-level changes relative to the predecessor.
330    pub changes: &'a [FieldChange<'a>],
331}
332
333impl EvolutionSpec<'_> {
334    /// The genesis evolution: no predecessor, no changes.
335    pub const GENESIS: EvolutionSpec<'static> = EvolutionSpec {
336        migrates_from: None,
337        changes: &[],
338    };
339}
340
341/// A single field-level change between two adjacent schema versions.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum FieldChange<'a> {
344    /// A field introduced in this version.
345    Added {
346        /// Name of the new field.
347        name: &'a str,
348    },
349    /// A field present in the predecessor but dropped here.
350    Removed {
351        /// Name of the dropped field.
352        name: &'a str,
353    },
354    /// A field carried over under a new name.
355    Renamed {
356        /// Name in the predecessor.
357        from: &'a str,
358        /// Name in this version.
359        to: &'a str,
360    },
361    /// A field whose type changed, requiring an explicit transform.
362    Transformed {
363        /// Name of the transformed field.
364        name: &'a str,
365    },
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    // A hand-written manifest exercising the const data model exactly as the
373    // derive macro will emit it. Layout figures come from `core::mem`, so the
374    // numbers are whatever the compiler actually chose for `Sample`.
375    #[repr(C)]
376    struct Sample {
377        a: u32,
378        b: u16,
379    }
380
381    impl Ix for Sample {
382        const MANIFEST: Manifest<'static> = Manifest {
383            type_name: "ix_schema::tests::Sample",
384            schema_version: 1,
385            layout: LayoutSpec {
386                size: core::mem::size_of::<Sample>(),
387                align: core::mem::align_of::<Sample>(),
388                repr: Repr::C,
389            },
390            fields: &[
391                FieldSpec {
392                    name: "a",
393                    type_name: "u32",
394                    offset: core::mem::offset_of!(Sample, a),
395                    size: core::mem::size_of::<u32>(),
396                    align: core::mem::align_of::<u32>(),
397                    since: 1,
398                },
399                FieldSpec {
400                    name: "b",
401                    type_name: "u16",
402                    offset: core::mem::offset_of!(Sample, b),
403                    size: core::mem::size_of::<u16>(),
404                    align: core::mem::align_of::<u16>(),
405                    since: 1,
406                },
407            ],
408            variants: &[],
409            evolution: EvolutionSpec::GENESIS,
410        };
411    }
412
413    #[test]
414    fn manifest_reports_declared_shape() {
415        let m = Sample::MANIFEST;
416        assert_eq!(m.schema_version, 1);
417        assert_eq!(m.layout.repr, Repr::C);
418        assert_eq!(m.fields.len(), 2);
419        assert_eq!(m.fields[0].name, "a");
420        assert_eq!(m.fields[0].offset, 0);
421        assert_eq!(m.evolution, EvolutionSpec::GENESIS);
422    }
423
424    #[test]
425    fn padding_matches_real_layout() {
426        // u32 + u16 = 6 bytes of data; #[repr(C)] rounds size up to 8 (align 4),
427        // so exactly 2 padding bytes — computed by const fn, no runtime cost.
428        let m = Sample::MANIFEST;
429        assert_eq!(m.packed_field_bytes(), 6);
430        assert_eq!(m.layout.size, 8);
431        assert_eq!(m.padding_bytes(), 2);
432        assert!(!m.is_gap_free());
433    }
434
435    // A driver whose support is decided entirely at compile time from the
436    // manifest — the whole point of the orchestrator design.
437    struct ZerocopyDriver;
438    impl<T: Ix> Driver<T> for ZerocopyDriver {
439        const SUPPORTED: bool = matches!(T::MANIFEST.layout.repr, Repr::C | Repr::Transparent)
440            && T::MANIFEST.is_gap_free();
441    }
442
443    #[test]
444    fn driver_support_folds_from_const_manifest() {
445        // Sample is repr(C) but has padding, so a zerocopy driver must reject it.
446        // The const block proves it during compilation; the runtime check keeps
447        // the test observable.
448        const { assert!(!<ZerocopyDriver as Driver<Sample>>::SUPPORTED) };
449        let supported = <ZerocopyDriver as Driver<Sample>>::SUPPORTED;
450        assert!(!supported);
451    }
452
453    #[test]
454    fn retyping_a_carried_field_breaks_extends() {
455        // Two manifests with one field each: same name, offset and size, but the
456        // spelled type changes `u32` -> `f32`. The bytes would be reinterpreted,
457        // so an append-only "extension" check must reject it. Proven at compile
458        // time via the const block, confirmed at runtime for observability.
459        const OLD: Manifest<'static> = Manifest {
460            type_name: "X",
461            schema_version: 1,
462            layout: LayoutSpec {
463                size: 4,
464                align: 4,
465                repr: Repr::C,
466            },
467            fields: &[FieldSpec {
468                name: "v",
469                type_name: "u32",
470                offset: 0,
471                size: 4,
472                align: 4,
473                since: 1,
474            }],
475            variants: &[],
476            evolution: EvolutionSpec::GENESIS,
477        };
478        const NEW: Manifest<'static> = Manifest {
479            type_name: "X",
480            schema_version: 2,
481            layout: LayoutSpec {
482                size: 4,
483                align: 4,
484                repr: Repr::C,
485            },
486            fields: &[FieldSpec {
487                name: "v",
488                type_name: "f32",
489                offset: 0,
490                size: 4,
491                align: 4,
492                since: 1,
493            }],
494            variants: &[],
495            evolution: EvolutionSpec::GENESIS,
496        };
497        const { assert!(!NEW.extends(&OLD)) };
498        assert!(!NEW.extends(&OLD));
499        // Sanity: an identical manifest still extends itself.
500        assert!(OLD.extends(&OLD));
501    }
502
503    #[test]
504    fn migration_trait_is_type_safe() {
505        // Two adjacent schema versions wired by MigrateFrom; the body is what a
506        // derive would emit. Field types must line up or this would not compile.
507        #[repr(C)]
508        struct V1 {
509            id: u32,
510        }
511        #[repr(C)]
512        struct V2 {
513            id: u32,
514            // introduced in v2
515            flags: u8,
516        }
517        impl Ix for V1 {
518            const MANIFEST: Manifest<'static> = Manifest {
519                type_name: "V1",
520                schema_version: 1,
521                layout: LayoutSpec {
522                    size: core::mem::size_of::<V1>(),
523                    align: core::mem::align_of::<V1>(),
524                    repr: Repr::C,
525                },
526                fields: &[],
527                variants: &[],
528                evolution: EvolutionSpec::GENESIS,
529            };
530        }
531        impl Ix for V2 {
532            const MANIFEST: Manifest<'static> = Manifest {
533                type_name: "V2",
534                schema_version: 2,
535                layout: LayoutSpec {
536                    size: core::mem::size_of::<V2>(),
537                    align: core::mem::align_of::<V2>(),
538                    repr: Repr::C,
539                },
540                fields: &[],
541                variants: &[],
542                evolution: EvolutionSpec {
543                    migrates_from: Some(1),
544                    changes: &[FieldChange::Added { name: "flags" }],
545                },
546            };
547        }
548        impl MigrateFrom<V1> for V2 {
549            fn migrate_from(prev: V1) -> Self {
550                V2 {
551                    id: prev.id,
552                    flags: 0,
553                }
554            }
555        }
556
557        let v2 = V2::migrate_from(V1 { id: 7 });
558        assert_eq!(v2.id, 7);
559        assert_eq!(v2.flags, 0);
560        assert_eq!(V2::MANIFEST.evolution.migrates_from, Some(1));
561    }
562}