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    /// The type's doc comment (joined `///` lines), or `""` if undocumented.
122    pub doc: &'a str,
123    /// Monotonic schema version (`1` for the first revision).
124    pub schema_version: u32,
125    /// In-memory layout of the type.
126    pub layout: LayoutSpec,
127    /// Fields in declaration order (for a struct; empty for an enum).
128    pub fields: &'a [FieldSpec<'a>],
129    /// Variants in declaration order (for a fieldless enum; empty for a struct).
130    pub variants: &'a [VariantSpec<'a>],
131    /// How this version relates to its predecessor.
132    pub evolution: EvolutionSpec<'a>,
133}
134
135impl Manifest<'_> {
136    /// Sum of the sizes of all fields, ignoring inter-field padding.
137    #[must_use]
138    pub const fn packed_field_bytes(&self) -> usize {
139        let mut sum = 0;
140        let mut i = 0;
141        while i < self.fields.len() {
142            sum += self.fields[i].size;
143            i += 1;
144        }
145        sum
146    }
147
148    /// Bytes of *top-level* padding the compiler inserted between or after this
149    /// type's own fields (`size - Σ field sizes`).
150    ///
151    /// This counts only inter-field and trailing padding at this level. It does
152    /// **not** see padding nested *inside* a field's own type, so a struct whose
153    /// only field is itself padded reports `0` here. A zerocopy driver therefore
154    /// uses this as a necessary cross-check, not as the sole proof of no padding
155    /// — the actual guarantee comes from `zerocopy`'s `IntoBytes` bound.
156    #[must_use]
157    pub const fn padding_bytes(&self) -> usize {
158        self.layout.size.saturating_sub(self.packed_field_bytes())
159    }
160
161    /// Whether the layout has no *top-level* padding holes (`size` equals the sum
162    /// of the field sizes). See [`Manifest::padding_bytes`] for the nested-padding
163    /// caveat: a gap-free top level can still wrap a field type that is itself
164    /// padded.
165    #[must_use]
166    pub const fn is_gap_free(&self) -> bool {
167        self.padding_bytes() == 0
168    }
169
170    /// Whether `self` is a layout-compatible, append-only extension of `prev`:
171    /// every field of `prev` is still present *under the same name*, at the same
172    /// offset, size and type.
173    ///
174    /// This is the compile-time precondition for evolving a type while keeping old
175    /// readers valid — new fields may only be appended, never inserted, reordered,
176    /// resized or retyped. Two strictnesses worth calling out:
177    /// * **Name is part of the contract.** Fields are matched by name, so a
178    ///   *renamed* field — even at the same offset, size and type — counts as
179    ///   absent and fails the check. That is stricter than raw byte layout (a
180    ///   rename keeps the bytes) but correct for name-based formats; express a
181    ///   rename as a migration (`rename_from`), not as an extension.
182    /// * **Type is part of the contract.** A field silently changed from `u32` to
183    ///   `f32` keeps the same offset and size but reinterprets the bytes, so it
184    ///   must not pass.
185    ///
186    /// Pair with [`assert_compatible!`].
187    #[must_use]
188    pub const fn extends(&self, prev: &Manifest<'_>) -> bool {
189        let mut i = 0;
190        while i < prev.fields.len() {
191            let want = prev.fields[i];
192            let mut matched = false;
193            let mut j = 0;
194            while j < self.fields.len() {
195                let have = self.fields[j];
196                if str_eq(have.name, want.name) {
197                    matched = have.offset == want.offset
198                        && have.size == want.size
199                        && str_eq(have.type_name, want.type_name);
200                    break;
201                }
202                j += 1;
203            }
204            if !matched {
205                return false;
206            }
207            i += 1;
208        }
209        true
210    }
211}
212
213/// `const`-evaluable string equality (`PartialEq` for `str` is not `const`).
214const fn str_eq(a: &str, b: &str) -> bool {
215    let a = a.as_bytes();
216    let b = b.as_bytes();
217    if a.len() != b.len() {
218        return false;
219    }
220    let mut i = 0;
221    while i < a.len() {
222        if a[i] != b[i] {
223            return false;
224        }
225        i += 1;
226    }
227    true
228}
229
230/// Statically assert that `$new` is a layout-compatible, append-only extension
231/// of `$old` (see [`Manifest::extends`]). Fails compilation if a carried field
232/// was moved, resized or dropped — catching wire-format breakage before runtime.
233///
234/// ```text
235/// ix_schema::assert_compatible!(EventV2 : EventV1);
236/// ```
237#[macro_export]
238macro_rules! assert_compatible {
239    ($new:ty : $old:ty) => {
240        const _: () = ::core::assert!(
241            <$new as $crate::Ix>::MANIFEST.extends(&<$old as $crate::Ix>::MANIFEST),
242            ::core::concat!(
243                stringify!($new),
244                " is not a layout-compatible extension of ",
245                stringify!($old),
246            ),
247        );
248    };
249}
250
251/// In-memory layout of a type.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct LayoutSpec {
254    /// `size_of::<T>()`.
255    pub size: usize,
256    /// `align_of::<T>()`.
257    pub align: usize,
258    /// The declared representation.
259    pub repr: Repr,
260}
261
262/// The `#[repr(..)]` of a type, as far as it affects layout stability.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum Repr {
265    /// Default Rust representation (layout not guaranteed stable).
266    Rust,
267    /// `#[repr(C)]` — stable, FFI-compatible layout.
268    C,
269    /// `#[repr(transparent)]`.
270    Transparent,
271    /// `#[repr(packed(n))]` with the given alignment.
272    Packed(usize),
273}
274
275/// Description of a single field within a [`Manifest`].
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub struct FieldSpec<'a> {
278    /// Field identifier.
279    pub name: &'a str,
280    /// The field's doc comment (joined `///` lines), or `""` if undocumented.
281    pub doc: &'a str,
282    /// Spelled type, e.g. `"u32"`.
283    pub type_name: &'a str,
284    /// Byte offset within the struct (`offset_of!`).
285    pub offset: usize,
286    /// `size_of` of the field type.
287    pub size: usize,
288    /// `align_of` of the field type.
289    pub align: usize,
290    /// Schema version in which the field was introduced.
291    pub since: u32,
292}
293
294/// How an enum variant carries its payload.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum VariantKind {
297    /// No payload — `Variant`.
298    Unit,
299    /// Positional payload — `Variant(A, B)`.
300    Tuple,
301    /// Named payload — `Variant { a: A }`.
302    Struct,
303}
304
305/// Description of a single payload field of a data-carrying enum variant.
306///
307/// Note the deliberate absence of an `offset`: Rust exposes no `const` way to
308/// read the byte offset of a field *within an enum variant* (`offset_of!` covers
309/// structs only). Rather than record a number it cannot verify — which would
310/// break the manifest's "computed by the compiler, cannot drift" guarantee — ix
311/// records only what it can prove: the field's name, spelled type, size and
312/// alignment.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub struct VariantFieldSpec<'a> {
315    /// Field name for a struct-like variant; `None` for a tuple position.
316    pub name: Option<&'a str>,
317    /// Spelled type, e.g. `"u32"`.
318    pub type_name: &'a str,
319    /// `size_of` of the field type.
320    pub size: usize,
321    /// `align_of` of the field type.
322    pub align: usize,
323}
324
325/// Description of a single enum variant.
326///
327/// A fieldless variant is fully described by its name and discriminant; a
328/// data-carrying variant additionally lists its payload [`VariantFieldSpec`]s.
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub struct VariantSpec<'a> {
331    /// Variant identifier.
332    pub name: &'a str,
333    /// The variant's doc comment (joined `///` lines), or `""` if undocumented.
334    pub doc: &'a str,
335    /// The variant's integer discriminant, when the compiler can evaluate it in
336    /// `const`: `Some` for a fieldless enum (emitted as `Self::Variant as i64`),
337    /// `None` for a data-carrying enum, whose variants cannot be cast to an int.
338    pub discriminant: Option<i64>,
339    /// Whether the variant is unit, tuple, or struct shaped.
340    pub kind: VariantKind,
341    /// The variant's payload fields (empty for a unit variant). Offsets are
342    /// intentionally absent — see [`VariantFieldSpec`].
343    pub fields: &'a [VariantFieldSpec<'a>],
344}
345
346/// How a schema version relates to the one before it.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub struct EvolutionSpec<'a> {
349    /// The version this one migrates from, or `None` for the genesis version.
350    pub migrates_from: Option<u32>,
351    /// Field-level changes relative to the predecessor.
352    pub changes: &'a [FieldChange<'a>],
353}
354
355impl EvolutionSpec<'_> {
356    /// The genesis evolution: no predecessor, no changes.
357    pub const GENESIS: EvolutionSpec<'static> = EvolutionSpec {
358        migrates_from: None,
359        changes: &[],
360    };
361}
362
363/// A single field-level change between two adjacent schema versions.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum FieldChange<'a> {
366    /// A field introduced in this version.
367    Added {
368        /// Name of the new field.
369        name: &'a str,
370    },
371    /// A field present in the predecessor but dropped here.
372    Removed {
373        /// Name of the dropped field.
374        name: &'a str,
375    },
376    /// A field carried over under a new name.
377    Renamed {
378        /// Name in the predecessor.
379        from: &'a str,
380        /// Name in this version.
381        to: &'a str,
382    },
383    /// A field whose type changed, requiring an explicit transform.
384    Transformed {
385        /// Name of the transformed field.
386        name: &'a str,
387    },
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    // A hand-written manifest exercising the const data model exactly as the
395    // derive macro will emit it. Layout figures come from `core::mem`, so the
396    // numbers are whatever the compiler actually chose for `Sample`.
397    #[repr(C)]
398    struct Sample {
399        a: u32,
400        b: u16,
401    }
402
403    impl Ix for Sample {
404        const MANIFEST: Manifest<'static> = Manifest {
405            type_name: "ix_schema::tests::Sample",
406            doc: "",
407            schema_version: 1,
408            layout: LayoutSpec {
409                size: core::mem::size_of::<Sample>(),
410                align: core::mem::align_of::<Sample>(),
411                repr: Repr::C,
412            },
413            fields: &[
414                FieldSpec {
415                    name: "a",
416                    doc: "",
417                    type_name: "u32",
418                    offset: core::mem::offset_of!(Sample, a),
419                    size: core::mem::size_of::<u32>(),
420                    align: core::mem::align_of::<u32>(),
421                    since: 1,
422                },
423                FieldSpec {
424                    name: "b",
425                    doc: "",
426                    type_name: "u16",
427                    offset: core::mem::offset_of!(Sample, b),
428                    size: core::mem::size_of::<u16>(),
429                    align: core::mem::align_of::<u16>(),
430                    since: 1,
431                },
432            ],
433            variants: &[],
434            evolution: EvolutionSpec::GENESIS,
435        };
436    }
437
438    #[test]
439    fn manifest_reports_declared_shape() {
440        let m = Sample::MANIFEST;
441        assert_eq!(m.schema_version, 1);
442        assert_eq!(m.layout.repr, Repr::C);
443        assert_eq!(m.fields.len(), 2);
444        assert_eq!(m.fields[0].name, "a");
445        assert_eq!(m.fields[0].offset, 0);
446        assert_eq!(m.evolution, EvolutionSpec::GENESIS);
447    }
448
449    #[test]
450    fn padding_matches_real_layout() {
451        // u32 + u16 = 6 bytes of data; #[repr(C)] rounds size up to 8 (align 4),
452        // so exactly 2 padding bytes — computed by const fn, no runtime cost.
453        let m = Sample::MANIFEST;
454        assert_eq!(m.packed_field_bytes(), 6);
455        assert_eq!(m.layout.size, 8);
456        assert_eq!(m.padding_bytes(), 2);
457        assert!(!m.is_gap_free());
458    }
459
460    // A driver whose support is decided entirely at compile time from the
461    // manifest — the whole point of the orchestrator design.
462    struct ZerocopyDriver;
463    impl<T: Ix> Driver<T> for ZerocopyDriver {
464        const SUPPORTED: bool = matches!(T::MANIFEST.layout.repr, Repr::C | Repr::Transparent)
465            && T::MANIFEST.is_gap_free();
466    }
467
468    #[test]
469    fn driver_support_folds_from_const_manifest() {
470        // Sample is repr(C) but has padding, so a zerocopy driver must reject it.
471        // The const block proves it during compilation; the runtime check keeps
472        // the test observable.
473        const { assert!(!<ZerocopyDriver as Driver<Sample>>::SUPPORTED) };
474        let supported = <ZerocopyDriver as Driver<Sample>>::SUPPORTED;
475        assert!(!supported);
476    }
477
478    #[test]
479    fn retyping_a_carried_field_breaks_extends() {
480        // Two manifests with one field each: same name, offset and size, but the
481        // spelled type changes `u32` -> `f32`. The bytes would be reinterpreted,
482        // so an append-only "extension" check must reject it. Proven at compile
483        // time via the const block, confirmed at runtime for observability.
484        const OLD: Manifest<'static> = Manifest {
485            type_name: "X",
486            doc: "",
487            schema_version: 1,
488            layout: LayoutSpec {
489                size: 4,
490                align: 4,
491                repr: Repr::C,
492            },
493            fields: &[FieldSpec {
494                name: "v",
495                doc: "",
496                type_name: "u32",
497                offset: 0,
498                size: 4,
499                align: 4,
500                since: 1,
501            }],
502            variants: &[],
503            evolution: EvolutionSpec::GENESIS,
504        };
505        const NEW: Manifest<'static> = Manifest {
506            type_name: "X",
507            doc: "",
508            schema_version: 2,
509            layout: LayoutSpec {
510                size: 4,
511                align: 4,
512                repr: Repr::C,
513            },
514            fields: &[FieldSpec {
515                name: "v",
516                doc: "",
517                type_name: "f32",
518                offset: 0,
519                size: 4,
520                align: 4,
521                since: 1,
522            }],
523            variants: &[],
524            evolution: EvolutionSpec::GENESIS,
525        };
526        const { assert!(!NEW.extends(&OLD)) };
527        assert!(!NEW.extends(&OLD));
528        // Sanity: an identical manifest still extends itself.
529        assert!(OLD.extends(&OLD));
530    }
531
532    #[test]
533    fn migration_trait_is_type_safe() {
534        // Two adjacent schema versions wired by MigrateFrom; the body is what a
535        // derive would emit. Field types must line up or this would not compile.
536        #[repr(C)]
537        struct V1 {
538            id: u32,
539        }
540        #[repr(C)]
541        struct V2 {
542            id: u32,
543            // introduced in v2
544            flags: u8,
545        }
546        impl Ix for V1 {
547            const MANIFEST: Manifest<'static> = Manifest {
548                type_name: "V1",
549                doc: "",
550                schema_version: 1,
551                layout: LayoutSpec {
552                    size: core::mem::size_of::<V1>(),
553                    align: core::mem::align_of::<V1>(),
554                    repr: Repr::C,
555                },
556                fields: &[],
557                variants: &[],
558                evolution: EvolutionSpec::GENESIS,
559            };
560        }
561        impl Ix for V2 {
562            const MANIFEST: Manifest<'static> = Manifest {
563                type_name: "V2",
564                doc: "",
565                schema_version: 2,
566                layout: LayoutSpec {
567                    size: core::mem::size_of::<V2>(),
568                    align: core::mem::align_of::<V2>(),
569                    repr: Repr::C,
570                },
571                fields: &[],
572                variants: &[],
573                evolution: EvolutionSpec {
574                    migrates_from: Some(1),
575                    changes: &[FieldChange::Added { name: "flags" }],
576                },
577            };
578        }
579        impl MigrateFrom<V1> for V2 {
580            fn migrate_from(prev: V1) -> Self {
581                V2 {
582                    id: prev.id,
583                    flags: 0,
584                }
585            }
586        }
587
588        let v2 = V2::migrate_from(V1 { id: 7 });
589        assert_eq!(v2.id, 7);
590        assert_eq!(v2.flags, 0);
591        assert_eq!(V2::MANIFEST.evolution.migrates_from, Some(1));
592    }
593}