feat(typedef/schema-types): implement TypeDef kind detection, annotations, Endian, $ref normalization

This commit is contained in:
glm-5.2 committed 2026-07-21 09:56:40 +00:00
1 parent 109d743f9f
commit ac36a1cee3
2 files changed
+593 -4

No files matched your search

+579 -2
View File
@@ -1,6 +1,583 @@
//! Schema layer: TypeDef kind detection, annotation parsing, `$ref`
//! normalization, and the `Endian` enum.
//!
//! Per ADR-097 and the schema-layer spec.
//! Per ADR-097 and the schema-layer spec. This module provides the
//! foundational types and functions that every other module depends on:
//! `TypeDef:*` kind detection, fixed byte-size lookups, natural alignment,
//! schema annotation parsing (`encoding`, `align`, `maxLength`, `endian`),
//! TUnion discriminator parsing, and `$ref` normalization from TypeBox
//! bare-name refs to JSON Pointer refs.
// TODO: implement
use crate::error::TypedefError;
use serde_json::Value;
const TYPEDEF_PREFIX: &str = "TypeDef:";
const BYTE_DISCRIMINATOR_TYPES: &[&str] = &[
"TypeDef:Uint8",
"TypeDef:Uint16",
"TypeDef:Uint32",
];
/// Returns the `TypeDef:*` kind string if the schema node declares one.
/// Returns `None` if the node has no `TypeDef:*` keyword.
///
/// A TypeDef kind is recognized when the schema object has a key starting
/// with `TypeDef:` whose value is `true`. (Object form with annotations
/// like `{ "encoding": "..." }` is handled by the annotation parsers, not
/// here — `get_typedef_kind` only checks for the presence of the keyword.)
pub fn get_typedef_kind(node: &Value) -> Option<&str> {
let obj = node.as_object()?;
for key in obj.keys() {
if key.starts_with(TYPEDEF_PREFIX) && obj.get(key) == Some(&Value::Bool(true)) {
return Some(key.as_str());
}
}
None
}
/// Returns the fixed byte size for a TypeDef kind, or `None` if variable-size
/// or composite (Struct/Union/Array/Record).
///
/// Only the 17 alknet-typedef kinds are recognized. `TypeDef:Int64` and
/// `TypeDef:Uint64` are NOT among the 17 kinds (TypeBox defines them but
/// alknet-typedef omits them), so they return `None` here.
pub fn type_size(kind: &str) -> Option<usize> {
match kind {
"TypeDef:Float32" | "TypeDef:Int32" | "TypeDef:Uint32" | "TypeDef:Enum" => Some(4),
"TypeDef:Float64" => Some(8),
"TypeDef:Int8" | "TypeDef:Uint8" | "TypeDef:Boolean" => Some(1),
"TypeDef:Int16" | "TypeDef:Uint16" => Some(2),
"TypeDef:String"
| "TypeDef:Bytes"
| "TypeDef:Struct"
| "TypeDef:Union"
| "TypeDef:Array"
| "TypeDef:Record"
| "TypeDef:Timestamp" => None,
_ => None,
}
}
/// Returns the natural alignment for a TypeDef kind.
///
/// Default alignment: 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/enum,
/// 8 for u64/i64/f64. Variable-length types (String/Bytes/Record/Timestamp)
/// align to 4 (the length prefix is u32). Composite types (Struct/Union/Array)
/// return 1 here — their alignment is computed from their fields during
/// offset computation.
pub fn natural_alignment(kind: &str) -> usize {
match kind {
"TypeDef:Int8" | "TypeDef:Uint8" | "TypeDef:Boolean" => 1,
"TypeDef:Int16" | "TypeDef:Uint16" => 2,
"TypeDef:Int32" | "TypeDef:Uint32" | "TypeDef:Float32" | "TypeDef:Enum" => 4,
"TypeDef:Float64" => 8,
"TypeDef:String" | "TypeDef:Bytes" | "TypeDef:Record" | "TypeDef:Timestamp" => 4,
"TypeDef:Struct" | "TypeDef:Union" | "TypeDef:Array" => 1,
_ => 1,
}
}
/// Returns true if the kind is a fixed-size type (known byte size at schema time).
pub fn is_fixed_size(kind: &str) -> bool {
matches!(
kind,
"TypeDef:Float32"
| "TypeDef:Float64"
| "TypeDef:Int8"
| "TypeDef:Int16"
| "TypeDef:Int32"
| "TypeDef:Uint8"
| "TypeDef:Uint16"
| "TypeDef:Uint32"
| "TypeDef:Boolean"
| "TypeDef:Enum"
)
}
/// Byte endianness for multi-byte integer and float fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Endian {
Little,
Big,
}
impl Endian {
/// Parse from the schema's `"endian"` annotation. Defaults to `Little`
/// if the annotation is absent or unrecognized.
pub fn from_schema(schema: &Value) -> Self {
parse_endian(schema)
}
}
/// The encoding strategy for a variable-length type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VariableEncoding {
/// `[length: u32][data]` — the default. Length prefix at a known offset,
/// variable data follows immediately.
LengthPrefixed,
/// `{offset: u32, length: u32}` pointing into a separate data region.
/// The metatensor blob tensor pattern.
OffsetIndirect,
}
/// Parse the `"encoding"` annotation from a variable-length type's keyword value.
///
/// The keyword value may be `true` (shorthand for length-prefixed) or an
/// object with an `"encoding"` field. Defaults to `LengthPrefixed` when
/// absent or unrecognized.
pub fn parse_encoding(keyword_value: &Value) -> VariableEncoding {
match keyword_value {
Value::Bool(true) => VariableEncoding::LengthPrefixed,
Value::Object(obj) => {
let encoding = obj.get("encoding").and_then(Value::as_str);
match encoding {
Some("offset-indirect") => VariableEncoding::OffsetIndirect,
_ => VariableEncoding::LengthPrefixed,
}
}
_ => VariableEncoding::LengthPrefixed,
}
}
/// Parse the `"align"` annotation from a schema node. Returns `None` if not
/// specified or not a non-negative integer.
pub fn parse_align(node: &Value) -> Option<usize> {
let n = node.as_object()?.get("align")?.as_u64()?;
Some(n as usize)
}
/// Parse the `"maxLength"` annotation (standard JSON Schema keyword).
/// Returns `None` if not specified or not a non-negative integer.
pub fn parse_max_length(node: &Value) -> Option<usize> {
let n = node.as_object()?.get("maxLength")?.as_u64()?;
Some(n as usize)
}
/// Parse the `"endian"` annotation. Defaults to `Little` if absent or
/// unrecognized. Operates on any node, not just the root.
pub fn parse_endian(node: &Value) -> Endian {
match node.as_object().and_then(|o| o.get("endian")).and_then(Value::as_str) {
Some("big") => Endian::Big,
_ => Endian::Little,
}
}
/// The kind of TUnion discriminator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscriminatorKind {
/// Byte-offset discriminator: a fixed-size integer at a known byte offset.
/// Mapping keys are stringified integers. Used by SFTP type bytes and
/// call protocol event types.
Byte {
/// Byte position of the discriminator within the union's buffer.
offset: usize,
/// The `TypeDef:*` kind of the discriminator (typically
/// `TypeDef:Uint8`).
disc_type: String,
},
/// Field-name discriminator: a named field within the struct. Mapping keys
/// are string values matching the discriminator field's value. The
/// typedef.ts pattern.
Field {
/// The field name that holds the discriminator value.
name: String,
},
}
/// Parse the `"discriminator"` annotation from a TUnion schema node.
///
/// Returns [`TypedefError::Schema`] for malformed discriminators (unknown
/// `kind`, missing required `name`, or an unsupported discriminator `type`).
pub fn parse_discriminator(node: &Value) -> Result<DiscriminatorKind, TypedefError> {
let obj = node.as_object().ok_or_else(|| {
TypedefError::Schema("discriminator requires a schema object".to_string())
})?;
let disc = obj.get("discriminator").ok_or_else(|| {
TypedefError::Schema("union is missing 'discriminator' annotation".to_string())
})?;
let disc_obj = disc.as_object().ok_or_else(|| {
TypedefError::Schema("'discriminator' must be an object".to_string())
})?;
let kind = disc_obj.get("kind").and_then(Value::as_str).ok_or_else(|| {
TypedefError::Schema("discriminator is missing 'kind' field".to_string())
})?;
match kind {
"byte" => {
let offset = disc_obj
.get("offset")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let disc_type = disc_obj
.get("type")
.and_then(Value::as_str)
.unwrap_or("TypeDef:Uint8")
.to_string();
if !BYTE_DISCRIMINATOR_TYPES.contains(&disc_type.as_str()) {
return Err(TypedefError::Schema(format!(
"discriminator 'type' must be one of {BYTE_DISCRIMINATOR_TYPES:?}, got {disc_type:?}"
)));
}
Ok(DiscriminatorKind::Byte { offset, disc_type })
}
"field" => {
let name = disc_obj
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| {
TypedefError::Schema(
"field discriminator is missing required 'name' field".to_string(),
)
})?
.to_string();
Ok(DiscriminatorKind::Field { name })
}
other => Err(TypedefError::Schema(format!(
"unknown discriminator 'kind': {other:?} (expected \"byte\" or \"field\")"
))),
}
}
/// Walk the schema tree. For every `"$ref"` whose value is a bare name
/// (no `#` prefix), rewrite it to `"#/$defs/<name>"`. Full JSON Pointer refs
/// (starting with `#`) pass through unchanged. Idempotent.
pub fn normalize_refs(schema: &mut Value) {
normalize_refs_recursive(schema);
}
fn normalize_refs_recursive(node: &mut Value) {
if let Value::Object(obj) = node {
if let Some(Value::String(ref s)) = obj.get("$ref") {
if !s.starts_with('#') && !s.is_empty() {
let new_ref = format!("#/$defs/{s}");
if let Some(slot) = obj.get_mut("$ref") {
*slot = Value::String(new_ref);
}
}
}
for value in obj.values_mut() {
normalize_refs_recursive(value);
}
} else if let Value::Array(arr) = node {
for item in arr.iter_mut() {
normalize_refs_recursive(item);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn get_typedef_kind_detects_bool_keyword() {
let schema = json!({"TypeDef:Uint32": true});
assert_eq!(get_typedef_kind(&schema), Some("TypeDef:Uint32"));
}
#[test]
fn get_typedef_kind_ignores_object_keyword() {
let schema = json!({"TypeDef:String": {"encoding": "length-prefixed"}});
assert_eq!(get_typedef_kind(&schema), None);
}
#[test]
fn get_typedef_kind_none_for_plain_schema() {
let schema = json!({"type": "object", "properties": {}});
assert_eq!(get_typedef_kind(&schema), None);
}
#[test]
fn type_size_fixed_kinds() {
assert_eq!(type_size("TypeDef:Float32"), Some(4));
assert_eq!(type_size("TypeDef:Float64"), Some(8));
assert_eq!(type_size("TypeDef:Int8"), Some(1));
assert_eq!(type_size("TypeDef:Int16"), Some(2));
assert_eq!(type_size("TypeDef:Int32"), Some(4));
assert_eq!(type_size("TypeDef:Uint8"), Some(1));
assert_eq!(type_size("TypeDef:Uint16"), Some(2));
assert_eq!(type_size("TypeDef:Uint32"), Some(4));
assert_eq!(type_size("TypeDef:Boolean"), Some(1));
assert_eq!(type_size("TypeDef:Enum"), Some(4));
}
#[test]
fn type_size_variable_and_composite_kinds() {
for kind in [
"TypeDef:String",
"TypeDef:Bytes",
"TypeDef:Struct",
"TypeDef:Union",
"TypeDef:Array",
"TypeDef:Record",
"TypeDef:Timestamp",
] {
assert_eq!(type_size(kind), None, "failed for {kind}");
}
}
#[test]
fn type_size_unknown_kind_returns_none() {
assert_eq!(type_size("TypeDef:Int64"), None);
assert_eq!(type_size("TypeDef:Uint64"), None);
assert_eq!(type_size("not-a-typedef"), None);
}
#[test]
fn natural_alignment_matches_spec() {
assert_eq!(natural_alignment("TypeDef:Int8"), 1);
assert_eq!(natural_alignment("TypeDef:Uint8"), 1);
assert_eq!(natural_alignment("TypeDef:Boolean"), 1);
assert_eq!(natural_alignment("TypeDef:Int16"), 2);
assert_eq!(natural_alignment("TypeDef:Uint16"), 2);
assert_eq!(natural_alignment("TypeDef:Int32"), 4);
assert_eq!(natural_alignment("TypeDef:Uint32"), 4);
assert_eq!(natural_alignment("TypeDef:Float32"), 4);
assert_eq!(natural_alignment("TypeDef:Enum"), 4);
assert_eq!(natural_alignment("TypeDef:Float64"), 8);
assert_eq!(natural_alignment("TypeDef:String"), 4);
assert_eq!(natural_alignment("TypeDef:Bytes"), 4);
assert_eq!(natural_alignment("TypeDef:Record"), 4);
assert_eq!(natural_alignment("TypeDef:Timestamp"), 4);
assert_eq!(natural_alignment("TypeDef:Struct"), 1);
assert_eq!(natural_alignment("TypeDef:Union"), 1);
assert_eq!(natural_alignment("TypeDef:Array"), 1);
}
#[test]
fn is_fixed_size_classifies_correctly() {
for kind in [
"TypeDef:Float32",
"TypeDef:Float64",
"TypeDef:Int8",
"TypeDef:Int16",
"TypeDef:Int32",
"TypeDef:Uint8",
"TypeDef:Uint16",
"TypeDef:Uint32",
"TypeDef:Boolean",
"TypeDef:Enum",
] {
assert!(is_fixed_size(kind), "expected fixed: {kind}");
}
for kind in [
"TypeDef:String",
"TypeDef:Bytes",
"TypeDef:Struct",
"TypeDef:Union",
"TypeDef:Array",
"TypeDef:Record",
"TypeDef:Timestamp",
] {
assert!(!is_fixed_size(kind), "expected variable: {kind}");
}
}
#[test]
fn endian_from_schema_defaults_to_little() {
assert_eq!(Endian::from_schema(&json!({})), Endian::Little);
assert_eq!(Endian::from_schema(&json!({"endian": "little"})), Endian::Little);
assert_eq!(Endian::from_schema(&json!({"endian": "weird"})), Endian::Little);
}
#[test]
fn endian_from_schema_big() {
assert_eq!(Endian::from_schema(&json!({"endian": "big"})), Endian::Big);
}
#[test]
fn parse_encoding_shorthand_true() {
assert_eq!(parse_encoding(&json!(true)), VariableEncoding::LengthPrefixed);
}
#[test]
fn parse_encoding_object_length_prefixed() {
assert_eq!(
parse_encoding(&json!({"encoding": "length-prefixed"})),
VariableEncoding::LengthPrefixed
);
}
#[test]
fn parse_encoding_object_offset_indirect() {
assert_eq!(
parse_encoding(&json!({"encoding": "offset-indirect"})),
VariableEncoding::OffsetIndirect
);
}
#[test]
fn parse_encoding_unknown_defaults_to_length_prefixed() {
assert_eq!(
parse_encoding(&json!({"encoding": "weird"})),
VariableEncoding::LengthPrefixed
);
assert_eq!(parse_encoding(&json!(42)), VariableEncoding::LengthPrefixed);
assert_eq!(parse_encoding(&json!(null)), VariableEncoding::LengthPrefixed);
}
#[test]
fn parse_align_returns_value() {
assert_eq!(parse_align(&json!({"align": 256})), Some(256));
assert_eq!(parse_align(&json!({"align": 0})), Some(0));
}
#[test]
fn parse_align_none_when_absent() {
assert_eq!(parse_align(&json!({})), None);
assert_eq!(parse_align(&json!({"align": "not-a-number"})), None);
}
#[test]
fn parse_max_length_returns_value() {
assert_eq!(parse_max_length(&json!({"maxLength": 1024})), Some(1024));
}
#[test]
fn parse_max_length_none_when_absent() {
assert_eq!(parse_max_length(&json!({})), None);
assert_eq!(parse_max_length(&json!({"maxLength": "x"})), None);
}
#[test]
fn parse_endian_alias_matches_from_schema() {
assert_eq!(parse_endian(&json!({"endian": "big"})), Endian::Big);
assert_eq!(parse_endian(&json!({})), Endian::Little);
}
#[test]
fn parse_discriminator_byte_default_offset_and_type() {
let schema = json!({"discriminator": {"kind": "byte"}});
let disc = parse_discriminator(&schema).expect("byte discriminator");
assert_eq!(disc, DiscriminatorKind::Byte {
offset: 0,
disc_type: "TypeDef:Uint8".to_string(),
});
}
#[test]
fn parse_discriminator_byte_explicit() {
let schema = json!({
"discriminator": {"kind": "byte", "offset": 4, "type": "TypeDef:Uint16"}
});
let disc = parse_discriminator(&schema).expect("byte discriminator");
assert_eq!(disc, DiscriminatorKind::Byte {
offset: 4,
disc_type: "TypeDef:Uint16".to_string(),
});
}
#[test]
fn parse_discriminator_field() {
let schema = json!({"discriminator": {"kind": "field", "name": "type"}});
let disc = parse_discriminator(&schema).expect("field discriminator");
assert_eq!(disc, DiscriminatorKind::Field {
name: "type".to_string()
});
}
#[test]
fn parse_discriminator_missing_discriminator_is_error() {
let schema = json!({"TypeDef:Union": true});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn parse_discriminator_field_missing_name_is_error() {
let schema = json!({"discriminator": {"kind": "field"}});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn parse_discriminator_unknown_kind_is_error() {
let schema = json!({"discriminator": {"kind": "magic"}});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn parse_discriminator_byte_invalid_type_is_error() {
let schema = json!({
"discriminator": {"kind": "byte", "type": "TypeDef:Float32"}
});
assert!(matches!(
parse_discriminator(&schema),
Err(TypedefError::Schema(_))
));
}
#[test]
fn normalize_refs_rewrites_bare_name() {
let mut schema = json!({"$ref": "Read"});
normalize_refs(&mut schema);
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
}
#[test]
fn normalize_refs_leaves_pointer_ref_unchanged() {
let mut schema = json!({"$ref": "#/$defs/Read"});
normalize_refs(&mut schema);
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
}
#[test]
fn normalize_refs_is_idempotent() {
let mut schema = json!({"$ref": "Read"});
normalize_refs(&mut schema);
normalize_refs(&mut schema);
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
}
#[test]
fn normalize_refs_walks_nested_objects() {
let mut schema = json!({
"properties": {
"child": {"$ref": "Child"},
"other": {"$ref": "#/$defs/Other"}
},
"items": [
{"$ref": "InArray"},
{"foo": {"$ref": "Deep"}}
]
});
normalize_refs(&mut schema);
assert_eq!(
schema,
json!({
"properties": {
"child": {"$ref": "#/$defs/Child"},
"other": {"$ref": "#/$defs/Other"}
},
"items": [
{"$ref": "#/$defs/InArray"},
{"foo": {"$ref": "#/$defs/Deep"}}
]
})
);
}
#[test]
fn normalize_refs_preserves_sibling_keys() {
let mut schema = json!({
"$ref": "Read",
"typedef:annotation": "kept"
});
normalize_refs(&mut schema);
assert_eq!(
schema,
json!({
"$ref": "#/$defs/Read",
"typedef:annotation": "kept"
})
);
}
}
+14 -2
View File
@@ -1,7 +1,7 @@
---
id: typedef/schema-types
name: Implement TypeDef kind detection, schema annotation parsing, $ref normalization, and Endian enum
status: pending
status: completed
depends_on: [typedef/crate-init]
scope: moderate
risk: medium
@@ -180,4 +180,16 @@ passed to `jsonschema::validator_for` or the offset computation.
## Summary
> To be filled on completion
Implemented the schema layer in `crates/alknet-typedef/src/schema.rs` with full
coverage of all acceptance criteria. The module exposes `get_typedef_kind`,
`type_size`, `natural_alignment`, and `is_fixed_size` for the 17 TypeDef kinds;
the `Endian` enum with `from_schema`/`parse_endian` (defaulting to Little);
the `VariableEncoding` enum with `parse_encoding` (shorthand `true` and object
form with `length-prefixed`/`offset-indirect`); `parse_align`/`parse_max_length`
JSON Schema keyword readers; `parse_discriminator` returning the
`DiscriminatorKind::Byte`/`Field` variants with `TypedefError::Schema` on
malformed input; and `normalize_refs` rewriting bare-name `$ref`s to
`#/$defs/<name>` recursively and idempotently. 31 unit tests pass, and
`cargo check`, `cargo clippy -D warnings`, and `cargo build --workspace` all
succeed clean. The `TypedefError` dependency was already provided by the
parallel `error.rs` implementation (no stub needed).