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}