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).
1387 lines
47 KiB
Rust
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:?}");
|
|
}
|
|
} |