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}