Files
alktype/src/builder.rs
T
deepseek-v4-pro 510553d800 Remove the Timestamp kind
The Timestamp kind was a residual from an early research reference. It
was byte-identical to String everywhere (length-prefixed UTF-8) and its
only distinguishing behavior was a hand-rolled non-strict RFC 3339 check
that the docs admitted was incomplete (Feb 31 passes, seconds range
unchecked, no leap seconds). JSON-level timestamp validation is
jsonschema's job (format: date-time on the validate_json path), not
alktype's.

Removes the AlkTypeKind::Timestamp variant, its to_bast_str/from_bast_str
mapping, the builder's Schema::timestamp() constructor, the
validate_timestamp/is_rfc3339_timestamp validator arms, and the
materializer/reader/engine timestamp arms. Updates the meta-schema
primitive enum (14 -> 13), the spec docs (bast-format.md, schema-layer.md,
builder.md, validation.md, overview.md, data-access.md, README.md), and
the kind-count references (19 -> 18).

Verification: cargo test --release (407 pass), cargo clippy --all-targets
-- -D warnings (clean), cargo doc --no-deps (clean), cargo build --target
wasm32-unknown-unknown --release (clean).
2026-08-16 09:12:33 +00:00

1387 lines
47 KiB
Rust

//! Fluent Rust builder for constructing BAST documents and standard JSON
//! Schemas at runtime.
//!
//! Produces `serde_json::Value` — the same form [`crate::AlkTypeEngine`]
//! consumes. One builder, two output formats (D-BAST-008):
//!
//! - **BAST JSON** (binary layout) — `Schema::struct_().field(...).build()`
//! produces a BAST TypeDef (`{ "kind": "struct", "fields": [...] }`).
//! Primitive constructors produce bare TypeRef strings (`"uint32"`).
//! Feed to [`crate::AlkTypeEngine::compile`].
//! - **Standard JSON Schema** (JSON validation) — `Schema::object().field(...)`
//! produces `{ "type": "object", "properties": {...}, "required": [...] }`.
//! No BAST `kind`, no custom keywords — a plain JSON Schema. Feed to a
//! standard `jsonschema::Validator` (or [`crate::AlkTypeEngine::compile`]
//! with a JSON Schema for the `validate_json` path).
//!
//! The construction API distinguishes BAST kinds from standard JSON Schema
//! types via naming conventions (`string()` is the BAST primitive,
//! `string_()` is the standard JSON Schema type). Decided in ADR-009;
//! BAST output format per D-BAST-008.
//!
//! The builder is a thin construction layer over the JSON form. There is
//! no typed `Schema` enum; `.build()` returns a `Value`. Setters return
//! `Self` for chaining. Field order is load-bearing for binary layouts
//! (the `fields` array is ordered — no reliance on `serde_json`'s
//! `preserve_order` for correctness, per BAST design principle #4).
use crate::schema::{AlkTypeKind, Endian, VariableEncoding};
use serde_json::{json, Map, Value};
const REF_PREFIX: &str = "#/$defs/";
/// A schema under construction. One constructor per BAST kind or standard
/// JSON Schema type; setters for annotations and constraints; `.build()`
/// produces the final [`Value`].
///
/// See `docs/architecture/builder.md` for the full API spec.
///
/// # Two output formats
///
/// The builder internally tracks which representation a schema is building.
/// BAST primitives produce bare TypeRef strings on `.build()`; BAST
/// composites (struct/union/enum) and standard JSON Schema types produce
/// JSON objects. Annotations ([`endian`](Self::endian),
/// [`align`](Self::align), [`encoding`](Self::encoding),
/// [`max_length`](Self::max_length)) are stored on the `Schema` and
/// placed correctly by `.build()` (struct-level) or
/// [`field`](Self::field) (field-level).
#[derive(Debug, Clone)]
pub struct Schema {
repr: Repr,
endian: Option<Endian>,
align: Option<usize>,
encoding: Option<VariableEncoding>,
max_length: Option<usize>,
}
/// The internal representation being built. Distinguishes BAST
/// primitives (bare strings), BAST composites (objects with `kind`),
/// `$ref`s, and standard JSON Schema objects.
#[derive(Debug, Clone)]
enum Repr {
/// A BAST primitive — `.build()` produces the bare string `"uint32"`.
Primitive(AlkTypeKind),
/// A `$ref` to a named `$defs` entry.
Ref(String),
/// A BAST struct type (`kind: "struct"`).
Struct(StructBuilder),
/// A BAST union type (`kind: "union"`).
Union(UnionBuilder),
/// A BAST enum type (`kind: "enum"`).
Enum(Vec<String>),
/// A BAST array type (`kind: "array"`). `count` is required for
/// valid BAST (D-BAST-004); set via [`.count()`](Schema::count).
Array {
element: Box<Schema>,
count: Option<usize>,
},
/// A BAST record type (`kind: "record"`).
Record {
values: Box<Schema>,
},
/// A standard JSON Schema object (from `object()`, `string_()`, etc.)
/// or an object adopted via [`from_value`](Schema::from_value).
Standard(Map<String, Value>),
/// A non-object value adopted via [`from_value`](Schema::from_value).
/// Built as-is. Used when adopting a bare TypeRef string or any
/// non-object JSON value for composition.
Raw(Value),
}
/// Internal builder state for a BAST struct.
#[derive(Debug, Clone, Default)]
struct StructBuilder {
fields: Vec<FieldBuilder>,
}
/// Internal builder state for a BAST union.
#[derive(Debug, Clone)]
struct UnionBuilder {
discriminator: Discriminator,
fields: Vec<FieldBuilder>,
mapping: Vec<(String, Schema)>,
}
/// A field under construction within a struct, union, or standard JSON
/// Schema object. Stores the field name, the field's type [`Schema`],
/// and field-level annotations extracted from that schema by
/// [`Schema::build`] / [`Schema::field`].
#[derive(Debug, Clone)]
struct FieldBuilder {
name: String,
schema: Schema,
}
impl Schema {
// -----------------------------------------------------------------
// BAST primitive constructors
// -----------------------------------------------------------------
/// BAST `int8`.
pub fn int8() -> Self {
Self::primitive(AlkTypeKind::Int8)
}
/// BAST `int16`.
pub fn int16() -> Self {
Self::primitive(AlkTypeKind::Int16)
}
/// BAST `int32`.
pub fn int32() -> Self {
Self::primitive(AlkTypeKind::Int32)
}
/// BAST `int64`.
pub fn int64() -> Self {
Self::primitive(AlkTypeKind::Int64)
}
/// BAST `uint8`.
pub fn uint8() -> Self {
Self::primitive(AlkTypeKind::Uint8)
}
/// BAST `uint16`.
pub fn uint16() -> Self {
Self::primitive(AlkTypeKind::Uint16)
}
/// BAST `uint32`.
pub fn uint32() -> Self {
Self::primitive(AlkTypeKind::Uint32)
}
/// BAST `uint64`.
pub fn uint64() -> Self {
Self::primitive(AlkTypeKind::Uint64)
}
/// BAST `float32`.
pub fn float32() -> Self {
Self::primitive(AlkTypeKind::Float32)
}
/// BAST `float64`.
pub fn float64() -> Self {
Self::primitive(AlkTypeKind::Float64)
}
/// BAST `bool`.
pub fn boolean() -> Self {
Self::primitive(AlkTypeKind::Boolean)
}
/// BAST `string` (length-prefixed UTF-8).
pub fn string() -> Self {
Self::primitive(AlkTypeKind::String)
}
/// BAST `bytes` (length-prefixed raw bytes).
pub fn bytes() -> Self {
Self::primitive(AlkTypeKind::Bytes)
}
fn primitive(kind: AlkTypeKind) -> Self {
Self {
repr: Repr::Primitive(kind),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
// -----------------------------------------------------------------
// BAST composite constructors
// -----------------------------------------------------------------
/// BAST `struct` (record of fields; add via `.field()`).
pub fn struct_() -> Self {
Self {
repr: Repr::Struct(StructBuilder::default()),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
/// BAST `array` (fixed-size; set count via `.count()`). The element
/// type is set at construction time. `count` is required for valid
/// BAST (D-BAST-004) — without it, the built value will be rejected
/// at `compile` time.
pub fn array_of(element: Schema) -> Self {
Self {
repr: Repr::Array {
element: Box::new(element),
count: None,
},
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
/// BAST `record` (string-keyed map of values).
pub fn record_of(value: Schema) -> Self {
Self {
repr: Repr::Record {
values: Box::new(value),
},
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
/// BAST `union` (tagged union; add variants via `.mapping()`).
/// For field-name discriminators, add the discriminator field and
/// any shared fields via `.field()`.
pub fn union_(disc: Discriminator) -> Self {
Self {
repr: Repr::Union(UnionBuilder {
discriminator: disc,
fields: Vec::new(),
mapping: Vec::new(),
}),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
/// BAST `enum` with the provided values in declaration order.
/// The binary representation is a `u32` index into this list.
pub fn enum_of(values: &[&str]) -> Self {
Self {
repr: Repr::Enum(values.iter().map(|v| (*v).to_string()).collect()),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
// -----------------------------------------------------------------
// Standard JSON Schema type constructors (no BAST `kind`)
// -----------------------------------------------------------------
/// Standard `type: "object"`. Fields via `.field()`, required via
/// `.required()`.
pub fn object() -> Self {
Self::with_type("object")
}
/// Standard `type: "array"`. Items via `.items()`.
pub fn array() -> Self {
Self::with_type("array")
}
/// Standard `type: "string"`.
pub fn string_() -> Self {
Self::with_type("string")
}
/// Standard `type: "integer"`.
pub fn integer() -> Self {
Self::with_type("integer")
}
/// Standard `type: "number"`.
pub fn number() -> Self {
Self::with_type("number")
}
/// Standard `type: "boolean"`.
pub fn boolean_() -> Self {
Self::with_type("boolean")
}
/// Standard `type: "null"`.
pub fn null() -> Self {
Self::with_type("null")
}
/// No type constraint (the `{}` form).
pub fn any() -> Self {
Self {
repr: Repr::Standard(Map::new()),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
fn with_type(type_str: &str) -> Self {
let mut object = Map::new();
object.insert("type".to_string(), Value::String(type_str.to_string()));
Self {
repr: Repr::Standard(object),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
/// Adopt an existing JSON `Value` as a `Schema` for composition.
/// Object values are adopted as-is; non-object values (e.g. a bare
/// BAST primitive string) are wrapped and built as-is.
pub fn from_value(value: Value) -> Self {
let repr = match value {
Value::Object(map) => Repr::Standard(map),
other => Repr::Raw(other),
};
Self {
repr,
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
/// Produce a `{"$ref": "#/$defs/<name>"}` schema.
pub fn ref_def(name: &str) -> Self {
Self {
repr: Repr::Ref(name.to_string()),
endian: None,
align: None,
encoding: None,
max_length: None,
}
}
// -----------------------------------------------------------------
// Annotation setters (ADR-003, BAST type-level/field-level properties)
// -----------------------------------------------------------------
/// Endianness. On a struct/union, sets the type-level default. On a
/// field's type, sets a field-level override (extracted by
/// `.field()`). Default little.
pub fn endian(mut self, endian: Endian) -> Self {
self.endian = Some(endian);
self
}
/// Alignment. On a struct, sets the struct-level default. On a
/// field's type, sets a field-level override. Only meaningful in
/// aligned static mode (ADR-002/003).
pub fn align(mut self, align: usize) -> Self {
self.align = Some(align);
self
}
/// Variable-length encoding strategy. Set on a field's type;
/// extracted by `.field()` and emitted as a field-level property.
/// Default `length-prefixed` (emitted only when explicitly set).
pub fn encoding(mut self, encoding: VariableEncoding) -> Self {
self.encoding = Some(encoding);
self
}
/// `maxLength` — byte-length cap. On a BAST field's type, extracted
/// by `.field()` as a field-level constraint. On a standard JSON
/// Schema type, emitted as the standard `maxLength` keyword.
/// In aligned mode with a variable-length type, reserves this many
/// bytes (strategy 2). In packed mode, validation constraint only.
pub fn max_length(mut self, max: usize) -> Self {
match &mut self.repr {
Repr::Standard(map) => {
map.insert("maxLength".to_string(), Value::from(max as u64));
}
_ => {
self.max_length = Some(max);
}
}
self
}
/// Set the fixed element count for a BAST array. Required for valid
/// BAST (D-BAST-004). No-op on non-array schemas.
pub fn count(mut self, count: usize) -> Self {
if let Repr::Array {
count: c, ..
} = &mut self.repr
{
*c = Some(count);
}
self
}
// -----------------------------------------------------------------
// Composite builders
// -----------------------------------------------------------------
/// Add a field to a struct, union (field-name discriminator), or
/// standard JSON Schema object.
///
/// **BAST struct/union:** the field is appended to the ordered
/// `fields` array as `{ "name": ..., "kind": <TypeRef>, ...annotations }`.
/// Field-level annotations (`.endian()`, `.align()`, `.encoding()`,
/// `.max_length()` on the field's type) are extracted and emitted
/// as field properties. Field order is load-bearing (packed mode
/// field order = byte order).
///
/// **Standard JSON Schema object:** the field is inserted into
/// `properties[name] = field.build()` (standard JSON Schema form).
pub fn field(mut self, name: &str, field: Schema) -> Self {
match &mut self.repr {
Repr::Struct(s) => {
s.fields.push(FieldBuilder {
name: name.to_string(),
schema: field,
});
}
Repr::Union(u) => {
u.fields.push(FieldBuilder {
name: name.to_string(),
schema: field,
});
}
Repr::Standard(map) => {
let props = map
.entry("properties".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut();
if let Some(props) = props {
props.insert(name.to_string(), field.build());
}
}
_ => {}
}
self
}
/// Mark fields as required (standard JSON Schema `required` keyword).
/// Can be called multiple times; required names accumulate.
/// Only applies to standard JSON Schema objects; no-op on BAST types
/// (BAST structs have all fields present by construction).
pub fn required(mut self, names: &[&str]) -> Self {
if let Repr::Standard(map) = &mut self.repr {
let entry = map
.entry("required".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if let Some(arr) = entry.as_array_mut() {
for n in names {
arr.push(Value::String((*n).to_string()));
}
}
}
self
}
/// Set the items schema for a standard `array` type. No-op on
/// non-standard-array schemas.
pub fn items(mut self, item: Schema) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("items".to_string(), item.build());
}
self
}
/// Set `additionalProperties` for a standard `object` type. No-op on
/// non-standard-object schemas.
pub fn additional_properties(mut self, props: Schema) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("additionalProperties".to_string(), props.build());
}
self
}
/// Add a variant to a BAST union. `disc_value` is the stringified
/// discriminator value (mapping key). The variant schema is built
/// into a TypeRef. No-op on non-union schemas.
pub fn mapping(mut self, disc_value: &str, variant: Schema) -> Self {
if let Repr::Union(u) = &mut self.repr {
u.mapping.push((disc_value.to_string(), variant));
}
self
}
// -----------------------------------------------------------------
// Constraint setters (standard JSON Schema)
// -----------------------------------------------------------------
/// `minimum` (inclusive lower bound for numbers/integers).
/// Standard JSON Schema keyword; only applies to standard types.
pub fn minimum(mut self, min: f64) -> Self {
if let Repr::Standard(map) = &mut self.repr {
if let Some(n) = serde_json::Number::from_f64(min) {
map.insert("minimum".to_string(), Value::Number(n));
}
}
self
}
/// `maximum` (inclusive upper bound for numbers/integers).
/// Standard JSON Schema keyword; only applies to standard types.
pub fn maximum(mut self, max: f64) -> Self {
if let Repr::Standard(map) = &mut self.repr {
if let Some(n) = serde_json::Number::from_f64(max) {
map.insert("maximum".to_string(), Value::Number(n));
}
}
self
}
/// `minLength` (minimum string length). Standard JSON Schema keyword.
pub fn min_length(mut self, min: usize) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("minLength".to_string(), Value::from(min as u64));
}
self
}
/// `minItems` (minimum array length). Standard JSON Schema keyword.
pub fn min_items(mut self, min: usize) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("minItems".to_string(), Value::from(min as u64));
}
self
}
/// `maxItems` (maximum array length). Standard JSON Schema keyword.
pub fn max_items(mut self, max: usize) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("maxItems".to_string(), Value::from(max as u64));
}
self
}
/// `format` (e.g. "date-time", "uri", "email"). Standard JSON Schema
/// keyword.
pub fn format(mut self, fmt: &str) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("format".to_string(), Value::String(fmt.to_string()));
}
self
}
/// `title` (human-readable title). Standard JSON Schema keyword.
pub fn title(mut self, t: &str) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("title".to_string(), Value::String(t.to_string()));
}
self
}
/// `description` (human-readable description). Standard JSON Schema
/// keyword.
pub fn description(mut self, d: &str) -> Self {
if let Repr::Standard(map) = &mut self.repr {
map.insert("description".to_string(), Value::String(d.to_string()));
}
self
}
// -----------------------------------------------------------------
// Build
// -----------------------------------------------------------------
/// Produce the final `serde_json::Value`.
///
/// For BAST primitives, this is a bare string (`"uint32"`). For
/// BAST composites and standard JSON Schema types, this is a JSON
/// object. For `$ref`s, this is `{ "$ref": "#/$defs/<name>" }`.
pub fn build(self) -> Value {
let Schema {
repr,
endian,
align,
encoding: _,
max_length: _,
} = self;
match &repr {
Repr::Primitive(k) => Value::String(k.to_bast_str().to_string()),
Repr::Ref(name) => {
json!({ "$ref": format!("{REF_PREFIX}{name}") })
}
Repr::Struct(s) => {
let mut m = Map::new();
m.insert("kind".to_string(), Value::String("struct".to_string()));
if let Some(e) = endian {
m.insert("endian".to_string(), endian_value(e));
}
if let Some(a) = align {
m.insert("align".to_string(), Value::from(a as u64));
}
let fields = s.fields.iter().map(build_field_value).collect();
m.insert("fields".to_string(), Value::Array(fields));
Value::Object(m)
}
Repr::Union(u) => {
let mut m = Map::new();
m.insert("kind".to_string(), Value::String("union".to_string()));
if let Some(e) = endian {
m.insert("endian".to_string(), endian_value(e));
}
m.insert("discriminator".to_string(), discriminator_value(&u.discriminator));
if matches!(u.discriminator, Discriminator::Field { .. }) && !u.fields.is_empty() {
let fields = u.fields.iter().map(build_field_value).collect();
m.insert("fields".to_string(), Value::Array(fields));
}
let mapping = Map::from_iter(
u.mapping
.iter()
.map(|(k, v)| (k.clone(), v.clone().build())),
);
m.insert("mapping".to_string(), Value::Object(mapping));
Value::Object(m)
}
Repr::Enum(values) => {
let mut m = Map::new();
m.insert("kind".to_string(), Value::String("enum".to_string()));
m.insert(
"values".to_string(),
Value::Array(values.iter().map(|v| Value::String(v.clone())).collect()),
);
Value::Object(m)
}
Repr::Array { element, count } => {
let mut m = Map::new();
m.insert("kind".to_string(), Value::String("array".to_string()));
m.insert("element".to_string(), element.clone().build());
if let Some(c) = count {
m.insert("count".to_string(), Value::from(*c as u64));
}
Value::Object(m)
}
Repr::Record { values } => {
let mut m = Map::new();
m.insert("kind".to_string(), Value::String("record".to_string()));
m.insert("values".to_string(), values.clone().build());
Value::Object(m)
}
Repr::Standard(map) => Value::Object(map.clone()),
Repr::Raw(v) => v.clone(),
}
}
}
/// Build a single BAST field object from a [`FieldBuilder`], extracting
/// field-level annotations from the field's type schema.
fn build_field_value(f: &FieldBuilder) -> Value {
let mut m = Map::new();
m.insert("name".to_string(), Value::String(f.name.clone()));
m.insert("kind".to_string(), f.schema.clone().build());
if let Some(e) = f.schema.endian {
m.insert("endian".to_string(), endian_value(e));
}
if let Some(a) = f.schema.align {
m.insert("align".to_string(), Value::from(a as u64));
}
if let Some(enc) = f.schema.encoding {
m.insert(
"encoding".to_string(),
Value::String(
match enc {
VariableEncoding::LengthPrefixed => "length-prefixed",
VariableEncoding::OffsetIndirect => "offset-indirect",
}
.to_string(),
),
);
}
if let Some(ml) = f.schema.max_length {
m.insert("maxLength".to_string(), Value::from(ml as u64));
}
Value::Object(m)
}
fn endian_value(e: Endian) -> Value {
Value::String(
match e {
Endian::Little => "little",
Endian::Big => "big",
}
.to_string(),
)
}
fn discriminator_value(disc: &Discriminator) -> Value {
match disc {
Discriminator::Byte { offset, disc_type } => Value::Object(Map::from_iter([
("kind".to_string(), Value::String("byte".to_string())),
("offset".to_string(), Value::from(*offset as u64)),
("type".to_string(), Value::String(disc_type.to_bast_str().to_string())),
])),
Discriminator::Field { name } => Value::Object(Map::from_iter([
("kind".to_string(), Value::String("field".to_string())),
("name".to_string(), Value::String(name.clone())),
])),
}
}
/// TUnion discriminator, builder-friendly form. Mirrors
/// [`crate::bast::BastDiscriminator`] but constructed via the builder
/// API.
#[derive(Debug, Clone)]
pub enum Discriminator {
/// Byte-offset discriminator. `offset` is the byte position; `disc_type`
/// is the AlkType kind of the discriminator (Uint8/Uint16/Uint32).
Byte {
/// Byte position of the discriminator within the union's buffer.
offset: usize,
/// The AlkType kind of the discriminator (typically Uint8).
disc_type: AlkTypeKind,
},
/// Field-name discriminator. `name` is the field holding the
/// discriminator value. The discriminator field and any shared
/// fields are declared via `.field()` on the union builder.
Field {
/// The field name that holds the discriminator value.
name: String,
},
}
/// Helper for building named `$defs` that schemas can `$ref` by name.
///
/// `define` returns a `Schema` (the `$ref` to the definition), so it can
/// be passed directly to `.field()` / `.mapping()` without a separate
/// `ref_def` call. Use [`build`](Self::build) to get the `{"$defs": { ... }}`
/// object, or [`build_doc`](Self::build_doc) to produce a complete BAST
/// document with a named root type.
#[derive(Debug, Default)]
pub struct Definitions {
defs: Map<String, Value>,
}
impl Definitions {
/// Create an empty `Definitions` registry.
pub fn new() -> Self {
Self::default()
}
/// Define a named schema. Returns a `Schema` that produces
/// `{"$ref": "#/$defs/<name>"}`.
pub fn define(&mut self, name: &str, schema: Schema) -> Schema {
self.defs.insert(name.to_string(), schema.build());
Schema::ref_def(name)
}
/// Like `define`, but the schema is an existing `Value` (adopted via
/// `Schema::from_value`).
pub fn define_value(&mut self, name: &str, value: Value) -> Schema {
self.defs.insert(name.to_string(), value);
Schema::ref_def(name)
}
/// Produce the `{"$defs": { ... }}` object.
pub fn build(self) -> Value {
Value::Object(Map::from_iter([(
"$defs".to_string(),
Value::Object(self.defs),
)]))
}
/// Build a complete BAST document with `root_name` as the root type.
///
/// The root schema is inserted into `$defs` alongside any previously
/// defined entries. The resulting `Value` is ready for
/// [`crate::AlkTypeEngine::compile`]:
///
/// ```rust,ignore
/// let mut defs = Definitions::new();
/// defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
/// let doc = defs.build_doc("Packet", Schema::struct_()
/// .field("payload", Schema::ref_def("Read")));
/// let engine = AlkTypeEngine::compile(&doc, "Packet", LayoutMode::Packed, None)?;
/// ```
pub fn build_doc(self, root_name: &str, root: Schema) -> Value {
let mut defs = self.defs;
defs.insert(root_name.to_string(), root.build());
Value::Object(Map::from_iter([(
"$defs".to_string(),
Value::Object(defs),
)]))
}
/// Merge the `$defs` into a top-level schema `Value`. Convenience
/// for the common case of building a schema that references its
/// definitions. If `top` already has a `$defs` object, the definitions
/// are merged into it (existing keys with the same name are
/// overwritten); otherwise a `$defs` key is inserted.
///
/// For BAST documents, prefer [`build_doc`](Self::build_doc) — it
/// places the root type inside `$defs` (where BAST requires it).
/// This method is useful when merging additional definitions into an
/// existing BAST document that already has a `$defs` block.
pub fn merge_into(self, top: &mut Value) {
if let Some(obj) = top.as_object_mut() {
match obj.get_mut("$defs").and_then(Value::as_object_mut) {
Some(existing) => existing.extend(self.defs),
None => {
obj.insert("$defs".to_string(), Value::Object(self.defs));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ----- Primitive constructors ---------------------------------------
#[test]
fn builder_uint32_produces_bast_string() {
assert_eq!(Schema::uint32().build(), json!("uint32"));
}
#[test]
fn builder_all_primitives_produce_bast_strings() {
assert_eq!(Schema::int8().build(), json!("int8"));
assert_eq!(Schema::int16().build(), json!("int16"));
assert_eq!(Schema::int32().build(), json!("int32"));
assert_eq!(Schema::int64().build(), json!("int64"));
assert_eq!(Schema::uint8().build(), json!("uint8"));
assert_eq!(Schema::uint16().build(), json!("uint16"));
assert_eq!(Schema::uint32().build(), json!("uint32"));
assert_eq!(Schema::uint64().build(), json!("uint64"));
assert_eq!(Schema::float32().build(), json!("float32"));
assert_eq!(Schema::float64().build(), json!("float64"));
assert_eq!(Schema::boolean().build(), json!("bool"));
assert_eq!(Schema::string().build(), json!("string"));
assert_eq!(Schema::bytes().build(), json!("bytes"));
}
// ----- Struct -------------------------------------------------------
#[test]
fn builder_struct_with_endian_and_fields() {
let s = Schema::struct_()
.endian(Endian::Big)
.field("channel_id", Schema::uint32())
.field("length", Schema::uint32())
.build();
assert_eq!(
s,
json!({
"kind": "struct",
"endian": "big",
"fields": [
{ "name": "channel_id", "kind": "uint32" },
{ "name": "length", "kind": "uint32" }
]
})
);
}
#[test]
fn builder_struct_with_align() {
let s = Schema::struct_().align(256).field("x", Schema::uint8()).build();
assert_eq!(s["align"], json!(256));
assert_eq!(s["kind"], json!("struct"));
}
#[test]
fn builder_field_order_preserved() {
let s = Schema::struct_()
.field("first", Schema::uint8())
.field("second", Schema::uint16())
.field("third", Schema::uint32())
.build();
let fields = s["fields"].as_array().unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0]["name"], json!("first"));
assert_eq!(fields[1]["name"], json!("second"));
assert_eq!(fields[2]["name"], json!("third"));
}
#[test]
fn builder_field_with_endian_override() {
let s = Schema::struct_()
.endian(Endian::Big)
.field("crc", Schema::uint32().endian(Endian::Little))
.build();
assert_eq!(
s["fields"][0],
json!({ "name": "crc", "kind": "uint32", "endian": "little" })
);
}
#[test]
fn builder_field_with_encoding_and_max_length() {
let s = Schema::struct_()
.field(
"blob",
Schema::bytes()
.encoding(VariableEncoding::OffsetIndirect)
.max_length(256),
)
.build();
assert_eq!(
s["fields"][0],
json!({
"name": "blob",
"kind": "bytes",
"encoding": "offset-indirect",
"maxLength": 256
})
);
}
#[test]
fn builder_field_encoding_length_prefixed_emitted_explicitly() {
let s = Schema::struct_()
.field("s", Schema::string().encoding(VariableEncoding::LengthPrefixed))
.build();
assert_eq!(
s["fields"][0],
json!({
"name": "s",
"kind": "string",
"encoding": "length-prefixed"
})
);
}
#[test]
fn builder_field_without_annotations_is_minimal() {
let s = Schema::struct_().field("id", Schema::uint32()).build();
assert_eq!(
s["fields"][0],
json!({ "name": "id", "kind": "uint32" })
);
}
#[test]
fn builder_struct_default_endian_is_omitted() {
let s = Schema::struct_().field("x", Schema::uint8()).build();
assert!(
s.get("endian").is_none(),
"default endian should not be emitted"
);
}
// ----- Standard JSON Schema object ---------------------------------
#[test]
fn builder_object_with_required_and_constraints() {
let s = Schema::object()
.field("path", Schema::string_().max_length(4096))
.field("offset", Schema::integer().minimum(0.0))
.field("length", Schema::integer().minimum(0.0))
.required(&["path"])
.build();
assert_eq!(
s,
json!({
"type": "object",
"properties": {
"path": {"type": "string", "maxLength": 4096},
"offset": {"type": "integer", "minimum": 0.0},
"length": {"type": "integer", "minimum": 0.0}
},
"required": ["path"]
})
);
}
#[test]
fn builder_required_accumulates_across_calls() {
let s = Schema::object()
.field("a", Schema::integer())
.field("b", Schema::integer())
.required(&["a"])
.required(&["b"])
.build();
assert_eq!(s["required"], json!(["a", "b"]));
}
// ----- Union --------------------------------------------------------
#[test]
fn builder_union_byte_discriminator_with_mapping() {
let s = Schema::union_(Discriminator::Byte {
offset: 0,
disc_type: AlkTypeKind::Uint8,
})
.mapping("5", Schema::ref_def("Read"))
.mapping("6", Schema::ref_def("Write"))
.build();
assert_eq!(
s,
json!({
"kind": "union",
"discriminator": {"kind": "byte", "offset": 0, "type": "uint8"},
"mapping": {
"5": {"$ref": "#/$defs/Read"},
"6": {"$ref": "#/$defs/Write"}
}
})
);
}
#[test]
fn builder_union_field_discriminator() {
let s = Schema::union_(Discriminator::Field { name: "type".to_string() })
.field("type", Schema::string())
.mapping("read", Schema::ref_def("Read"))
.build();
assert_eq!(
s["discriminator"],
json!({"kind": "field", "name": "type"})
);
assert_eq!(
s["fields"],
json!([{ "name": "type", "kind": "string" }])
);
assert_eq!(
s["mapping"],
json!({ "read": {"$ref": "#/$defs/Read"} })
);
}
#[test]
fn builder_union_byte_disc_no_fields_key() {
let s = Schema::union_(Discriminator::Byte {
offset: 0,
disc_type: AlkTypeKind::Uint8,
})
.mapping("1", Schema::ref_def("Init"))
.build();
assert!(
s.get("fields").is_none(),
"byte-offset union should not have a fields key"
);
}
#[test]
fn builder_union_with_endian() {
let s = Schema::union_(Discriminator::Byte {
offset: 0,
disc_type: AlkTypeKind::Uint8,
})
.endian(Endian::Big)
.mapping("1", Schema::ref_def("Init"))
.build();
assert_eq!(s["endian"], json!("big"));
}
// ----- Enum ---------------------------------------------------------
#[test]
fn builder_enum_of_with_values() {
let s = Schema::enum_of(&["read", "write", "execute"]).build();
assert_eq!(
s,
json!({
"kind": "enum",
"values": ["read", "write", "execute"]
})
);
}
// ----- Array and Record --------------------------------------------
#[test]
fn builder_array_of_with_count() {
let arr = Schema::array_of(Schema::uint32()).count(3).build();
assert_eq!(
arr,
json!({"kind": "array", "element": "uint32", "count": 3})
);
}
#[test]
fn builder_array_of_without_count_omits_count() {
let arr = Schema::array_of(Schema::uint32()).build();
assert_eq!(
arr,
json!({"kind": "array", "element": "uint32"})
);
}
#[test]
fn builder_array_of_with_ref_element() {
let arr = Schema::array_of(Schema::ref_def("Point")).count(4).build();
assert_eq!(
arr,
json!({
"kind": "array",
"element": {"$ref": "#/$defs/Point"},
"count": 4
})
);
}
#[test]
fn builder_record_of() {
let rec = Schema::record_of(Schema::float32()).build();
assert_eq!(
rec,
json!({"kind": "record", "values": "float32"})
);
}
// ----- $ref and from_value ------------------------------------------
#[test]
fn builder_ref_def_produces_json_pointer() {
assert_eq!(
Schema::ref_def("Read").build(),
json!({"$ref": "#/$defs/Read"})
);
}
#[test]
fn builder_from_value_adopts_existing_schema() {
let existing = json!({"type": "integer", "minimum": 0});
let s = Schema::from_value(existing.clone());
assert_eq!(s.build(), existing);
}
#[test]
fn builder_from_value_adopts_bare_string() {
let s = Schema::from_value(json!("uint32"));
assert_eq!(s.build(), json!("uint32"));
}
#[test]
fn builder_any_is_empty_object() {
assert_eq!(Schema::any().build(), json!({}));
}
// ----- Encoding setter ----------------------------------------------
#[test]
fn builder_encoding_on_primitive_does_not_affect_build() {
// Annotations on a primitive are for field() to extract;
// build() of a bare primitive is just the string.
let s = Schema::string().encoding(VariableEncoding::OffsetIndirect).build();
assert_eq!(s, json!("string"));
}
#[test]
fn builder_encoding_offset_indirect_in_field() {
let s = Schema::struct_()
.field("handle", Schema::string().encoding(VariableEncoding::OffsetIndirect))
.build();
assert_eq!(
s["fields"][0],
json!({
"name": "handle",
"kind": "string",
"encoding": "offset-indirect"
})
);
}
#[test]
fn builder_encoding_length_prefixed_in_field() {
let s = Schema::struct_()
.field("s", Schema::string().encoding(VariableEncoding::LengthPrefixed))
.build();
assert_eq!(
s["fields"][0]["encoding"],
json!("length-prefixed")
);
}
#[test]
fn builder_encoding_not_set_is_omitted() {
let s = Schema::struct_().field("s", Schema::string()).build();
assert!(
s["fields"][0].get("encoding").is_none(),
"encoding should be omitted when not set"
);
}
// ----- Definitions --------------------------------------------------
#[test]
fn builder_definitions_define_returns_ref() {
let mut defs = Definitions::new();
let r = defs.define("FileNotFound", Schema::object().field("path", Schema::string_()));
assert_eq!(r.build(), json!({"$ref": "#/$defs/FileNotFound"}));
let built = defs.build();
assert_eq!(
built["$defs"]["FileNotFound"],
json!({"type": "object", "properties": {"path": {"type": "string"}}})
);
}
#[test]
fn builder_definitions_define_struct_produces_bast() {
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
let built = defs.build();
assert_eq!(
built["$defs"]["Read"],
json!({
"kind": "struct",
"fields": [{ "name": "id", "kind": "uint32" }]
})
);
}
#[test]
fn builder_build_doc_produces_complete_bast_document() {
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
let doc = defs.build_doc(
"Packet",
Schema::struct_().field("payload", Schema::ref_def("Read")),
);
assert_eq!(
doc,
json!({
"$defs": {
"Read": {
"kind": "struct",
"fields": [{ "name": "id", "kind": "uint32" }]
},
"Packet": {
"kind": "struct",
"fields": [
{ "name": "payload", "kind": {"$ref": "#/$defs/Read"} }
]
}
}
})
);
}
#[test]
fn builder_build_doc_compiles() {
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
let doc = defs.build_doc(
"Packet",
Schema::struct_()
.endian(Endian::Big)
.field("payload", Schema::ref_def("Read")),
);
let engine = crate::AlkTypeEngine::compile(
&doc,
"Packet",
crate::LayoutMode::Packed,
None,
);
assert!(engine.is_ok(), "build_doc output should compile: {engine:?}");
}
#[test]
fn builder_merge_into_inserts_defs_when_absent() {
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
let mut top = Schema::struct_().field("payload", Schema::ref_def("Read")).build();
defs.merge_into(&mut top);
assert!(top["$defs"].is_object(), "$defs should be inserted");
assert_eq!(
top["$defs"]["Read"],
json!({"kind": "struct", "fields": [{ "name": "id", "kind": "uint32" }]})
);
}
#[test]
fn builder_merge_into_merges_into_existing_defs() {
let mut top = json!({
"$defs": {
"Write": {"kind": "struct", "fields": [{ "name": "n", "kind": "uint16" }]}
}
});
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
defs.merge_into(&mut top);
assert_eq!(
top["$defs"]["Write"],
json!({"kind": "struct", "fields": [{ "name": "n", "kind": "uint16" }]}),
"existing Write def should be preserved"
);
assert_eq!(
top["$defs"]["Read"],
json!({"kind": "struct", "fields": [{ "name": "id", "kind": "uint32" }]}),
"new Read def should be merged in"
);
}
#[test]
fn builder_merge_into_overwrites_duplicate_keys() {
let mut top = json!({ "$defs": { "X": {"old": true} } });
let mut defs = Definitions::new();
defs.define_value("X", json!({"new": true}));
defs.merge_into(&mut top);
assert_eq!(top["$defs"]["X"], json!({"new": true}));
}
#[test]
fn builder_merge_into_is_noop_for_non_object_top() {
let mut top = json!("not an object");
let mut defs = Definitions::new();
defs.define("Read", Schema::uint32());
defs.merge_into(&mut top);
assert_eq!(top, json!("not an object"), "non-object top should be untouched");
}
// ----- End-to-end: builder → compile --------------------------------
#[test]
fn builder_chunk_header_compiles_in_packed_mode() {
let defs = Definitions::new();
let doc = defs.build_doc(
"ChunkHeader",
Schema::struct_()
.endian(Endian::Big)
.field("channel_id", Schema::uint32())
.field("length", Schema::uint32()),
);
let engine = crate::AlkTypeEngine::compile(
&doc,
"ChunkHeader",
crate::LayoutMode::Packed,
None,
);
assert!(engine.is_ok(), "chunk header schema should compile: {engine:?}");
}
#[test]
fn builder_sftp_style_union_compiles() {
let mut defs = Definitions::new();
defs.define("Init", Schema::struct_().field("version", Schema::uint32()));
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
let doc = defs.build_doc(
"Packet",
Schema::struct_().field(
"payload",
Schema::union_(Discriminator::Byte {
offset: 0,
disc_type: AlkTypeKind::Uint8,
})
.mapping("1", Schema::ref_def("Init"))
.mapping("5", Schema::ref_def("Read")),
),
);
let engine = crate::AlkTypeEngine::compile(
&doc,
"Packet",
crate::LayoutMode::Packed,
None,
);
assert!(engine.is_ok(), "sftp-style union should compile: {engine:?}");
}
#[test]
fn builder_array_with_count_compiles() {
let doc = Definitions::new().build_doc(
"Mesh",
Schema::struct_().field(
"vertices",
Schema::array_of(Schema::float32()).count(3),
),
);
let engine = crate::AlkTypeEngine::compile(
&doc,
"Mesh",
crate::LayoutMode::Packed,
None,
);
assert!(engine.is_ok(), "array with count should compile: {engine:?}");
}
#[test]
fn builder_record_compiles() {
let doc = Definitions::new().build_doc(
"Config",
Schema::struct_().field("entries", Schema::record_of(Schema::uint32())),
);
let engine = crate::AlkTypeEngine::compile(
&doc,
"Config",
crate::LayoutMode::Packed,
None,
);
assert!(engine.is_ok(), "record should compile: {engine:?}");
}
}