diff --git a/src/builder.rs b/src/builder.rs index ecdc5b5..0573e1e 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,159 +1,270 @@ -//! Fluent Rust builder for constructing alktype JSON Schemas at runtime. +//! Fluent Rust builder for constructing BAST documents and standard JSON +//! Schemas at runtime. //! //! Produces `serde_json::Value` — the same form [`crate::AlkTypeEngine`] -//! consumes. Covers the 19 `AlkType:*` kinds and the standard JSON Schema -//! keywords needed for operation payload schemas. Decided in ADR-009; -//! spec in `docs/architecture/builder.md`. +//! 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 `AlkType:*` keywords. 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 -//! (`serde_json`'s `preserve_order` feature preserves insertion order). +//! (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::{Map, Value}; +use serde_json::{json, Map, Value}; -const ALKTYPE_PREFIX: &str = "AlkType:"; +const REF_PREFIX: &str = "#/$defs/"; -/// A schema under construction. One constructor per AlkType kind or -/// standard JSON Schema type; setters for annotations and constraints; -/// `.build()` produces the final [`Value`]. +/// 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 { - /// The accumulated JSON object. Built incrementally via setters. - /// Keys preserve insertion order under `serde_json`'s `preserve_order` - /// feature, which is load-bearing for binary layouts (field order - /// = byte order in packed mode). - object: Map, + repr: Repr, + endian: Option, + align: Option, + encoding: Option, + max_length: Option, +} + +/// 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), + /// A BAST array type (`kind: "array"`). `count` is required for + /// valid BAST (D-BAST-004); set via [`.count()`](Schema::count). + Array { + element: Box, + count: Option, + }, + /// A BAST record type (`kind: "record"`). + Record { + values: Box, + }, + /// A standard JSON Schema object (from `object()`, `string_()`, etc.) + /// or an object adopted via [`from_value`](Schema::from_value). + Standard(Map), + /// 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, +} + +/// Internal builder state for a BAST union. +#[derive(Debug, Clone)] +struct UnionBuilder { + discriminator: Discriminator, + fields: Vec, + 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 { // ----------------------------------------------------------------- - // AlkType kind constructors + // BAST primitive constructors // ----------------------------------------------------------------- - /// `AlkType:Int8`. + /// BAST `int8`. pub fn int8() -> Self { - Self::with_kind(AlkTypeKind::Int8) + Self::primitive(AlkTypeKind::Int8) } - /// `AlkType:Int16`. + /// BAST `int16`. pub fn int16() -> Self { - Self::with_kind(AlkTypeKind::Int16) + Self::primitive(AlkTypeKind::Int16) } - /// `AlkType:Int32`. + /// BAST `int32`. pub fn int32() -> Self { - Self::with_kind(AlkTypeKind::Int32) + Self::primitive(AlkTypeKind::Int32) } - /// `AlkType:Int64`. + /// BAST `int64`. pub fn int64() -> Self { - Self::with_kind(AlkTypeKind::Int64) + Self::primitive(AlkTypeKind::Int64) } - /// `AlkType:Uint8`. + /// BAST `uint8`. pub fn uint8() -> Self { - Self::with_kind(AlkTypeKind::Uint8) + Self::primitive(AlkTypeKind::Uint8) } - /// `AlkType:Uint16`. + /// BAST `uint16`. pub fn uint16() -> Self { - Self::with_kind(AlkTypeKind::Uint16) + Self::primitive(AlkTypeKind::Uint16) } - /// `AlkType:Uint32`. + /// BAST `uint32`. pub fn uint32() -> Self { - Self::with_kind(AlkTypeKind::Uint32) + Self::primitive(AlkTypeKind::Uint32) } - /// `AlkType:Uint64`. + /// BAST `uint64`. pub fn uint64() -> Self { - Self::with_kind(AlkTypeKind::Uint64) + Self::primitive(AlkTypeKind::Uint64) } - /// `AlkType:Float32`. + /// BAST `float32`. pub fn float32() -> Self { - Self::with_kind(AlkTypeKind::Float32) + Self::primitive(AlkTypeKind::Float32) } - /// `AlkType:Float64`. + /// BAST `float64`. pub fn float64() -> Self { - Self::with_kind(AlkTypeKind::Float64) + Self::primitive(AlkTypeKind::Float64) } - /// `AlkType:Boolean`. + /// BAST `bool`. pub fn boolean() -> Self { - Self::with_kind(AlkTypeKind::Boolean) + Self::primitive(AlkTypeKind::Boolean) } - /// `AlkType:String` (length-prefixed UTF-8). + /// BAST `string` (length-prefixed UTF-8). pub fn string() -> Self { - Self::with_kind(AlkTypeKind::String) + Self::primitive(AlkTypeKind::String) } - /// `AlkType:Bytes` (length-prefixed raw bytes). + /// BAST `bytes` (length-prefixed raw bytes). pub fn bytes() -> Self { - Self::with_kind(AlkTypeKind::Bytes) + Self::primitive(AlkTypeKind::Bytes) } - /// `AlkType:Timestamp` (length-prefixed RFC 3339 string). + /// BAST `timestamp` (length-prefixed RFC 3339 string). pub fn timestamp() -> Self { - Self::with_kind(AlkTypeKind::Timestamp) + Self::primitive(AlkTypeKind::Timestamp) } - /// `AlkType:Struct` (record of fields; add via `.field()`). - pub fn struct_() -> Self { - Self::with_kind(AlkTypeKind::Struct) - } - /// `AlkType:Array` (repeated element). - pub fn array_of(element: Schema) -> Self { - let mut s = Self::with_kind(AlkTypeKind::Array); - s.object.insert("items".to_string(), element.build()); - s - } - /// `AlkType:Record` (string-keyed map of values). - pub fn record_of(value: Schema) -> Self { - let mut s = Self::with_kind(AlkTypeKind::Record); - s.object.insert("values".to_string(), value.build()); - s - } - /// `AlkType:Union` (tagged union; add variants via `.mapping()`). - pub fn union_(disc: Discriminator) -> Self { - let mut s = Self::with_kind(AlkTypeKind::Union); - match disc { - Discriminator::Byte { offset, disc_type } => { - s.object.insert( - "discriminator".to_string(), - 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.as_str().to_string())), - ])), - ); - } - Discriminator::Field { name } => { - s.object.insert( - "discriminator".to_string(), - Value::Object(Map::from_iter([ - ("kind".to_string(), Value::String("field".to_string())), - ("name".to_string(), Value::String(name)), - ])), - ); - } + + fn primitive(kind: AlkTypeKind) -> Self { + Self { + repr: Repr::Primitive(kind), + endian: None, + align: None, + encoding: None, + max_length: None, } - s } - /// `AlkType:Enum` with the provided values in declaration order. + + // ----------------------------------------------------------------- + // 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 { - let mut s = Self::with_kind(AlkTypeKind::Enum); - s.object.insert( - "enum".to_string(), - Value::Array(values.iter().map(|v| Value::String((*v).to_string())).collect()), - ); - s - } - - fn with_kind(kind: AlkTypeKind) -> Self { - let mut object = Map::new(); - object.insert(kind.as_str().to_string(), Value::Bool(true)); - Self { object } + 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 AlkType:* kinds) + // Standard JSON Schema type constructors (no BAST `kind`) // ----------------------------------------------------------------- - /// Standard `type: "object"`. Fields via `.field()`. + /// Standard `type: "object"`. Fields via `.field()`, required via + /// `.required()`. pub fn object() -> Self { Self::with_type("object") } @@ -183,127 +294,109 @@ impl Schema { } /// No type constraint (the `{}` form). pub fn any() -> Self { - Self { object: Map::new() } + 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 { object } + Self { + repr: Repr::Standard(object), + endian: None, + align: None, + encoding: None, + max_length: None, + } } - /// Adopt an existing JSON Schema `Value` as a `Schema` for composition. + /// 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 { - object: value - .as_object() - .cloned() - .unwrap_or_else(|| { - let mut m = Map::new(); - m.insert("".to_string(), value); - m - }), + repr, + endian: None, + align: None, + encoding: None, + max_length: None, } } /// Produce a `{"$ref": "#/$defs/"}` schema. pub fn ref_def(name: &str) -> Self { - let mut object = Map::new(); - object.insert( - "$ref".to_string(), - Value::String(format!("#/$defs/{name}")), - ); - Self { object } + Self { + repr: Repr::Ref(name.to_string()), + endian: None, + align: None, + encoding: None, + max_length: None, + } } // ----------------------------------------------------------------- - // Annotation setters (ADR-003) + // Annotation setters (ADR-003, BAST type-level/field-level properties) // ----------------------------------------------------------------- - /// Schema-level endianness. Default little. + /// 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.object.insert( - "endian".to_string(), - Value::String( - match endian { - Endian::Little => "little", - Endian::Big => "big", - } - .to_string(), - ), - ); + self.endian = Some(endian); self } - /// Struct or field alignment. + /// 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.object.insert("align".to_string(), Value::from(align as u64)); + self.align = Some(align); self } - /// Variable-length encoding strategy. + /// 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 { - // The `encoding` annotation lives inside the AlkType:* keyword's - // value object (see schema-layer.md §"Variable-length encoding"): - // { "AlkType:String": { "encoding": "offset-indirect" } } - // For the boolean-true shorthand (`{ "AlkType:String": true }`), - // `length-prefixed` is the implicit default and no `encoding` key - // is emitted. `offset-indirect` always requires the object form. - // If the keyword value is already an object (e.g. from a prior - // setter), update its `encoding` entry in place. - let kind_key = self - .object - .keys() - .find(|k| k.starts_with(ALKTYPE_PREFIX)) - .cloned(); - if let Some(k) = kind_key { - match encoding { - VariableEncoding::LengthPrefixed => { - // Only emit `encoding` when the keyword is already in - // object form — the boolean shorthand already means - // length-prefixed (the default), so emitting it would - // be redundant noise. - if let Some(Value::Object(obj)) = self.object.get_mut(&k) { - obj.insert( - "encoding".to_string(), - Value::String("length-prefixed".to_string()), - ); - } - } - VariableEncoding::OffsetIndirect => { - // Replace the boolean-true form with the object form - // carrying the encoding annotation. If the keyword is - // already an object, update its `encoding` entry. - match self.object.get_mut(&k) { - Some(Value::Object(obj)) => { - obj.insert( - "encoding".to_string(), - Value::String("offset-indirect".to_string()), - ); - } - _ => { - self.object.insert( - k, - Value::Object(Map::from_iter([ - ( - "encoding".to_string(), - Value::String("offset-indirect".to_string()), - ), - ])), - ); - } - } - } + 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 } - /// `maxLength` — standard JSON Schema 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 { - self.object.insert("maxLength".to_string(), Value::from(max as u64)); + /// 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 } @@ -311,56 +404,89 @@ impl Schema { // Composite builders // ----------------------------------------------------------------- - /// Add a field to a struct or object. Field order is load-bearing - /// for binary layouts (packed mode field order = byte order). + /// 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": , ...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 { - let props = self - .object - .entry("properties".to_string()) - .or_insert_with(|| Value::Object(Map::new())) - .as_object_mut() - .expect("properties must be an object"); - props.insert(name.to_string(), field.build()); + 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 { - let entry = self - .object - .entry("required".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - let arr = entry.as_array_mut().expect("required must be an array"); - for n in names { - arr.push(Value::String((*n).to_string())); + 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. + /// Set the items schema for a standard `array` type. No-op on + /// non-standard-array schemas. pub fn items(mut self, item: Schema) -> Self { - self.object.insert("items".to_string(), item.build()); + if let Repr::Standard(map) = &mut self.repr { + map.insert("items".to_string(), item.build()); + } self } - /// Set `additionalProperties` for a standard `object` type. + /// Set `additionalProperties` for a standard `object` type. No-op on + /// non-standard-object schemas. pub fn additional_properties(mut self, props: Schema) -> Self { - self.object - .insert("additionalProperties".to_string(), props.build()); + if let Repr::Standard(map) = &mut self.repr { + map.insert("additionalProperties".to_string(), props.build()); + } self } - /// Add a variant to a union. `disc_value` is the stringified - /// discriminator value (mapping key). + /// 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 { - let mapping = self - .object - .entry("mapping".to_string()) - .or_insert_with(|| Value::Object(Map::new())) - .as_object_mut() - .expect("mapping must be an object"); - mapping.insert(disc_value.to_string(), variant.build()); + if let Repr::Union(u) = &mut self.repr { + u.mapping.push((disc_value.to_string(), variant)); + } self } @@ -369,54 +495,74 @@ impl 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 { - self.object.insert("minimum".to_string(), serde_json::Number::from_f64(min) - .map(Value::Number) - .unwrap_or(Value::Null)); + 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 { - self.object.insert("maximum".to_string(), serde_json::Number::from_f64(max) - .map(Value::Number) - .unwrap_or(Value::Null)); + 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). + /// `minLength` (minimum string length). Standard JSON Schema keyword. pub fn min_length(mut self, min: usize) -> Self { - self.object.insert("minLength".to_string(), Value::from(min as u64)); + if let Repr::Standard(map) = &mut self.repr { + map.insert("minLength".to_string(), Value::from(min as u64)); + } self } - /// `minItems` (minimum array length). + /// `minItems` (minimum array length). Standard JSON Schema keyword. pub fn min_items(mut self, min: usize) -> Self { - self.object.insert("minItems".to_string(), Value::from(min as u64)); + if let Repr::Standard(map) = &mut self.repr { + map.insert("minItems".to_string(), Value::from(min as u64)); + } self } - /// `maxItems` (maximum array length). + /// `maxItems` (maximum array length). Standard JSON Schema keyword. pub fn max_items(mut self, max: usize) -> Self { - self.object.insert("maxItems".to_string(), Value::from(max as u64)); + 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"). + /// `format` (e.g. "date-time", "uri", "email"). Standard JSON Schema + /// keyword. pub fn format(mut self, fmt: &str) -> Self { - self.object.insert("format".to_string(), Value::String(fmt.to_string())); + if let Repr::Standard(map) = &mut self.repr { + map.insert("format".to_string(), Value::String(fmt.to_string())); + } self } - /// `title` (human-readable title). + /// `title` (human-readable title). Standard JSON Schema keyword. pub fn title(mut self, t: &str) -> Self { - self.object.insert("title".to_string(), Value::String(t.to_string())); + if let Repr::Standard(map) = &mut self.repr { + map.insert("title".to_string(), Value::String(t.to_string())); + } self } - /// `description` (human-readable description). + /// `description` (human-readable description). Standard JSON Schema + /// keyword. pub fn description(mut self, d: &str) -> Self { - self.object.insert("description".to_string(), Value::String(d.to_string())); + if let Repr::Standard(map) = &mut self.repr { + map.insert("description".to_string(), Value::String(d.to_string())); + } self } @@ -425,8 +571,136 @@ impl Schema { // ----------------------------------------------------------------- /// 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/" }`. pub fn build(self) -> Value { - Value::Object(self.object) + 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())), + ])), } } @@ -444,7 +718,8 @@ pub enum Discriminator { disc_type: AlkTypeKind, }, /// Field-name discriminator. `name` is the field holding the - /// discriminator value. + /// 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, @@ -454,9 +729,10 @@ pub enum Discriminator { /// 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()` / `.items()` without -/// a separate `ref_def` call. Call `.build()` once at the end to get the -/// `{"$defs": { ... }}` object to merge into a top-level schema. +/// 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, @@ -482,10 +758,34 @@ impl Definitions { Schema::ref_def(name) } - /// Produce the `{"$defs": { ... }}` object to merge into a top-level - /// schema. + /// Produce the `{"$defs": { ... }}` object. pub fn build(self) -> Value { - Value::Object(Map::from_iter([("$defs".to_string(), Value::Object(self.defs))])) + 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 @@ -493,6 +793,11 @@ impl Definitions { /// 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) { @@ -510,11 +815,33 @@ mod tests { use super::*; use serde_json::json; + // ----- Primitive constructors --------------------------------------- + #[test] - fn builder_uint32_produces_keyword() { - assert_eq!(Schema::uint32().build(), json!({"AlkType:Uint32": true})); + 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")); + assert_eq!(Schema::timestamp().build(), json!("timestamp")); + } + + // ----- Struct ------------------------------------------------------- + #[test] fn builder_struct_with_endian_and_fields() { let s = Schema::struct_() @@ -525,16 +852,23 @@ mod tests { assert_eq!( s, json!({ - "AlkType:Struct": true, + "kind": "struct", "endian": "big", - "properties": { - "channel_id": {"AlkType:Uint32": true}, - "length": {"AlkType:Uint32": true}, - } + "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_() @@ -542,11 +876,81 @@ mod tests { .field("second", Schema::uint16()) .field("third", Schema::uint32()) .build(); - let props = s["properties"].as_object().unwrap(); - let keys: Vec<&String> = props.keys().collect(); - assert_eq!(keys, vec!["first", "second", "third"]); + 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() @@ -580,6 +984,8 @@ mod tests { assert_eq!(s["required"], json!(["a", "b"])); } + // ----- Union -------------------------------------------------------- + #[test] fn builder_union_byte_discriminator_with_mapping() { let s = Schema::union_(Discriminator::Byte { @@ -592,11 +998,11 @@ mod tests { assert_eq!( s, json!({ - "AlkType:Union": true, - "discriminator": {"kind": "byte", "offset": 0, "type": "AlkType:Uint8"}, + "kind": "union", + "discriminator": {"kind": "byte", "offset": 0, "type": "uint8"}, "mapping": { "5": {"$ref": "#/$defs/Read"}, - "6": {"$ref": "#/$defs/Write"}, + "6": {"$ref": "#/$defs/Write"} } }) ); @@ -605,40 +1011,180 @@ mod tests { #[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!({ - "AlkType:Enum": true, - "enum": ["read", "write", "execute"] + "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_array_of_and_record_of() { - let arr = Schema::array_of(Schema::uint32()).build(); - assert_eq!( - arr, - json!({"AlkType:Array": true, "items": {"AlkType:Uint32": true}}) - ); + fn builder_record_of() { let rec = Schema::record_of(Schema::float32()).build(); assert_eq!( rec, - json!({"AlkType:Record": true, "values": {"AlkType:Float32": true}}) + 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(); @@ -651,6 +1197,66 @@ mod tests { ); } + #[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(); @@ -660,44 +1266,35 @@ mod tests { assert!(top["$defs"].is_object(), "$defs should be inserted"); assert_eq!( top["$defs"]["Read"], - json!({"AlkType:Struct": true, "properties": {"id": {"AlkType:Uint32": true}}}) + json!({"kind": "struct", "fields": [{ "name": "id", "kind": "uint32" }]}) ); - // The top-level schema's own keys are preserved. - assert_eq!(top["AlkType:Struct"], json!(true)); } #[test] fn builder_merge_into_merges_into_existing_defs() { - // Top already has a $defs with "Write"; merging in "Read" should - // add "Read" and keep "Write". - let mut top = Schema::struct_() - .field("payload", Schema::ref_def("Read")) - .build(); - top["$defs"] = json!({ - "Write": {"AlkType:Struct": true, "properties": {"n": {"AlkType:Uint16": true}}} + 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!({"AlkType:Struct": true, "properties": {"n": {"AlkType:Uint16": true}}}), + json!({"kind": "struct", "fields": [{ "name": "n", "kind": "uint16" }]}), "existing Write def should be preserved" ); assert_eq!( top["$defs"]["Read"], - json!({"AlkType:Struct": true, "properties": {"id": {"AlkType:Uint32": true}}}), + json!({"kind": "struct", "fields": [{ "name": "id", "kind": "uint32" }]}), "new Read def should be merged in" ); } #[test] fn builder_merge_into_overwrites_duplicate_keys() { - // If both top and defs define the same key, the merged value wins. - let mut top = json!({ - "AlkType:Struct": true, - "$defs": {"X": {"old": true}} - }); + 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); @@ -706,7 +1303,6 @@ mod tests { #[test] fn builder_merge_into_is_noop_for_non_object_top() { - // If top is not an object, merge_into silently does nothing. let mut top = json!("not an object"); let mut defs = Definitions::new(); defs.define("Read", Schema::uint32()); @@ -714,96 +1310,83 @@ mod tests { assert_eq!(top, json!("not an object"), "non-object top should be untouched"); } - #[test] - fn builder_ref_def_produces_json_pointer() { - assert_eq!( - Schema::ref_def("Read").build(), - json!({"$ref": "#/$defs/Read"}) - ); - } - - #[test] - fn builder_encoding_offset_indirect_rewrites_to_object_form() { - let s = Schema::string().encoding(VariableEncoding::OffsetIndirect).build(); - assert_eq!( - s, - json!({"AlkType:String": {"encoding": "offset-indirect"}}) - ); - } - - #[test] - fn builder_encoding_length_prefixed_on_boolean_form_is_noop() { - // The boolean-true shorthand already means length-prefixed (the - // default), so `.encoding(LengthPrefixed)` on a fresh constructor - // is a no-op — no `encoding` key is emitted. - let s = Schema::string().encoding(VariableEncoding::LengthPrefixed).build(); - assert_eq!(s, json!({"AlkType:String": true})); - } - - #[test] - fn builder_encoding_length_prefixed_overwrites_offset_indirect() { - // Switching from offset-indirect back to length-prefixed updates - // the `encoding` entry inside the keyword's object form in place. - let s = Schema::string() - .encoding(VariableEncoding::OffsetIndirect) - .encoding(VariableEncoding::LengthPrefixed) - .build(); - assert_eq!( - s, - json!({"AlkType:String": {"encoding": "length-prefixed"}}) - ); - } - - #[test] - fn builder_encoding_offset_indirect_overwrites_length_prefixed() { - // And the reverse: length-prefixed (object form) -> offset-indirect. - let s = Schema::string() - .encoding(VariableEncoding::LengthPrefixed) - .encoding(VariableEncoding::OffsetIndirect) - .build(); - assert_eq!( - s, - json!({"AlkType:String": {"encoding": "offset-indirect"}}) - ); - } - - #[test] - fn builder_align_sets_annotation() { - let s = Schema::struct_().align(256).build(); - assert_eq!(s["align"], json!(256)); - } - - #[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_any_is_empty_object() { - assert_eq!(Schema::any().build(), json!({})); - } + // ----- End-to-end: builder → compile -------------------------------- #[test] fn builder_chunk_header_compiles_in_packed_mode() { - // The builder still produces v0.1.0 `AlkType:*` format (step 7 - // converts it to BAST). Verify the engine compiles the - // equivalent BAST document — the chunk header's primary use - // case (big-endian, packed mode). - let doc = json!({ - "$defs": { - "ChunkHeader": { - "kind": "struct", - "endian": "big", - "fields": [ - { "name": "channel_id", "kind": "uint32" }, - { "name": "length", "kind": "uint32" } - ] - } - } - }); - let engine = crate::AlkTypeEngine::compile(&doc, "ChunkHeader", crate::LayoutMode::Packed, None); + 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:?}"); + } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 551b33e..0358bbd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,9 +26,10 @@ //! `jsonschema::Validator` from a consumer-provided JSON Schema for //! `validate_json` / `is_valid_json` (D-BAST-007) — no custom keywords, //! no BAST involvement. -//! - **Builder** ([`builder`]): Fluent Rust API for constructing alktype -//! JSON Schemas at runtime, producing `serde_json::Value` (ADR-009). -//! (Step 7 converts the builder to emit BAST JSON.) +//! - **Builder** ([`builder`]): Fluent Rust API for constructing BAST +//! documents (binary layout) and standard JSON Schemas (JSON +//! validation) at runtime, producing `serde_json::Value` (ADR-009, +//! D-BAST-008). `struct_()` → BAST, `object()` → standard JSON Schema. //! - **Materialize** ([`materialize`]): Materialize a `serde_json::Value` //! tree from a binary buffer by walking the BAST typed tree. Used by //! `AlkTypeEngine::validate_bytes` (ADR-010).