use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec::Vec;
use crate::codec::{
read_str, read_u8, read_u32, read_u64, write_str, write_u8, write_u32, write_u64,
};
use crate::inkb::safe_capacity;
use crate::opcode::DecodeError;
use crate::value::MAX_DECODE_DEPTH;
pub const CONVENTIONS_PROJECTION_WIRE_VERSION: u8 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ConventionsProjectionDef {
pub entries: Vec<ConventionEntryDef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionEntryDef {
pub name: String,
pub pattern: String,
pub order: i64,
pub mode: ConventionModeDef,
pub attach: Option<ConventionAttachDef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConventionModeDef {
Attach,
Wrap,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConventionAttachDef {
Resolved {
name: String,
fields: Vec<ConventionAttachFieldDef>,
},
Unresolved(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionAttachFieldDef {
pub name: String,
pub ty: SchemaTypeDef,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaTypeDef {
Named(String),
Generic {
name: String,
args: Vec<SchemaTypeDef>,
},
Fn {
params: Vec<SchemaTypeDef>,
ret: Box<SchemaTypeDef>,
},
}
const TAG_ATTACH: u8 = 0;
const TAG_WRAP: u8 = 1;
const TAG_RESOLVED: u8 = 0;
const TAG_UNRESOLVED: u8 = 1;
const TAG_TYPE_NAMED: u8 = 0;
const TAG_TYPE_GENERIC: u8 = 1;
const TAG_TYPE_FN: u8 = 2;
#[expect(clippy::cast_possible_truncation)]
pub fn write_conventions_projection(projection: &ConventionsProjectionDef, buf: &mut Vec<u8>) {
write_u8(buf, CONVENTIONS_PROJECTION_WIRE_VERSION);
write_u32(buf, projection.entries.len() as u32);
for entry in &projection.entries {
write_str(buf, &entry.name);
write_str(buf, &entry.pattern);
write_u64(buf, entry.order.cast_unsigned());
write_u8(
buf,
match entry.mode {
ConventionModeDef::Attach => TAG_ATTACH,
ConventionModeDef::Wrap => TAG_WRAP,
},
);
match &entry.attach {
None => write_u8(buf, 0),
Some(attach) => {
write_u8(buf, 1);
write_attach(attach, buf);
}
}
}
}
#[expect(clippy::cast_possible_truncation)]
fn write_attach(attach: &ConventionAttachDef, buf: &mut Vec<u8>) {
match attach {
ConventionAttachDef::Resolved { name, fields } => {
write_u8(buf, TAG_RESOLVED);
write_str(buf, name);
write_u32(buf, fields.len() as u32);
for field in fields {
write_str(buf, &field.name);
write_schema_type(&field.ty, buf);
}
}
ConventionAttachDef::Unresolved(name) => {
write_u8(buf, TAG_UNRESOLVED);
write_str(buf, name);
}
}
}
#[expect(clippy::cast_possible_truncation)]
fn write_schema_type(ty: &SchemaTypeDef, buf: &mut Vec<u8>) {
match ty {
SchemaTypeDef::Named(name) => {
write_u8(buf, TAG_TYPE_NAMED);
write_str(buf, name);
}
SchemaTypeDef::Generic { name, args } => {
write_u8(buf, TAG_TYPE_GENERIC);
write_str(buf, name);
write_u32(buf, args.len() as u32);
for arg in args {
write_schema_type(arg, buf);
}
}
SchemaTypeDef::Fn { params, ret } => {
write_u8(buf, TAG_TYPE_FN);
write_u32(buf, params.len() as u32);
for param in params {
write_schema_type(param, buf);
}
write_schema_type(ret, buf);
}
}
}
pub fn read_conventions_projection(
buf: &[u8],
offset: &mut usize,
) -> Result<ConventionsProjectionDef, DecodeError> {
let section_version = read_u8(buf, offset)?;
if section_version != CONVENTIONS_PROJECTION_WIRE_VERSION {
return Err(DecodeError::UnsupportedSectionVersion {
section: 0,
version: section_version,
});
}
let count = read_u32(buf, offset)? as usize;
let mut entries = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 18));
for _ in 0..count {
let name = read_str(buf, offset)?;
let pattern = read_str(buf, offset)?;
let order = read_u64(buf, offset)?.cast_signed();
let mode = match read_u8(buf, offset)? {
TAG_ATTACH => ConventionModeDef::Attach,
TAG_WRAP => ConventionModeDef::Wrap,
other => return Err(DecodeError::InvalidConventionsProjectionTag(other)),
};
let attach = match read_u8(buf, offset)? {
0 => None,
1 => Some(read_attach(buf, offset, 0)?),
other => return Err(DecodeError::InvalidConventionsProjectionTag(other)),
};
entries.push(ConventionEntryDef {
name,
pattern,
order,
mode,
attach,
});
}
Ok(ConventionsProjectionDef { entries })
}
fn read_attach(
buf: &[u8],
offset: &mut usize,
depth: usize,
) -> Result<ConventionAttachDef, DecodeError> {
match read_u8(buf, offset)? {
TAG_RESOLVED => {
let name = read_str(buf, offset)?;
let count = read_u32(buf, offset)? as usize;
let mut fields = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 5));
for _ in 0..count {
let field_name = read_str(buf, offset)?;
let ty = read_schema_type(buf, offset, depth)?;
fields.push(ConventionAttachFieldDef {
name: field_name,
ty,
});
}
Ok(ConventionAttachDef::Resolved { name, fields })
}
TAG_UNRESOLVED => Ok(ConventionAttachDef::Unresolved(read_str(buf, offset)?)),
other => Err(DecodeError::InvalidConventionsProjectionTag(other)),
}
}
fn read_schema_type(
buf: &[u8],
offset: &mut usize,
depth: usize,
) -> Result<SchemaTypeDef, DecodeError> {
if depth >= MAX_DECODE_DEPTH {
return Err(DecodeError::MaxDepthExceeded(MAX_DECODE_DEPTH));
}
match read_u8(buf, offset)? {
TAG_TYPE_NAMED => Ok(SchemaTypeDef::Named(read_str(buf, offset)?)),
TAG_TYPE_GENERIC => {
let name = read_str(buf, offset)?;
let count = read_u32(buf, offset)? as usize;
let mut args = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 1));
for _ in 0..count {
args.push(read_schema_type(buf, offset, depth + 1)?);
}
Ok(SchemaTypeDef::Generic { name, args })
}
TAG_TYPE_FN => {
let count = read_u32(buf, offset)? as usize;
let mut params = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 1));
for _ in 0..count {
params.push(read_schema_type(buf, offset, depth + 1)?);
}
let ret = Box::new(read_schema_type(buf, offset, depth + 1)?);
Ok(SchemaTypeDef::Fn { params, ret })
}
other => Err(DecodeError::InvalidConventionsProjectionTag(other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::string::ToString;
use alloc::vec;
fn sample() -> ConventionsProjectionDef {
ConventionsProjectionDef {
entries: vec![
ConventionEntryDef {
name: "cue".to_string(),
pattern: "^(?<name>[A-Z]+)$".to_string(),
order: 5,
mode: ConventionModeDef::Wrap,
attach: Some(ConventionAttachDef::Resolved {
name: "Cue".to_string(),
fields: vec![
ConventionAttachFieldDef {
name: "speaker".to_string(),
ty: SchemaTypeDef::Named("string".to_string()),
},
ConventionAttachFieldDef {
name: "voiceover".to_string(),
ty: SchemaTypeDef::Named("bool".to_string()),
},
],
}),
},
ConventionEntryDef {
name: "interior".to_string(),
pattern: "^INT\\. (?<place>.+)$".to_string(),
order: 10,
mode: ConventionModeDef::Attach,
attach: None,
},
ConventionEntryDef {
name: "broken".to_string(),
pattern: "^X$".to_string(),
order: 99,
mode: ConventionModeDef::Attach,
attach: Some(ConventionAttachDef::Unresolved("Ghost".to_string())),
},
],
}
}
#[test]
fn round_trips_a_mixed_projection() {
let projection = sample();
let mut buf = Vec::new();
write_conventions_projection(&projection, &mut buf);
let mut offset = 0;
let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
assert_eq!(decoded, projection);
assert_eq!(
offset,
buf.len(),
"reader must consume exactly what the writer wrote"
);
}
#[test]
fn round_trips_an_empty_projection() {
let projection = ConventionsProjectionDef::default();
let mut buf = Vec::new();
write_conventions_projection(&projection, &mut buf);
let mut offset = 0;
let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
assert_eq!(decoded, projection);
}
#[test]
fn round_trips_a_generic_and_fn_typed_field() {
let projection = ConventionsProjectionDef {
entries: vec![ConventionEntryDef {
name: "handler".to_string(),
pattern: "^Z$".to_string(),
order: 1,
mode: ConventionModeDef::Attach,
attach: Some(ConventionAttachDef::Resolved {
name: "Fancy".to_string(),
fields: vec![
ConventionAttachFieldDef {
name: "items".to_string(),
ty: SchemaTypeDef::Generic {
name: "List".to_string(),
args: vec![SchemaTypeDef::Named("L".to_string())],
},
},
ConventionAttachFieldDef {
name: "callback".to_string(),
ty: SchemaTypeDef::Fn {
params: vec![SchemaTypeDef::Named("int".to_string())],
ret: Box::new(SchemaTypeDef::Named("bool".to_string())),
},
},
],
}),
}],
};
let mut buf = Vec::new();
write_conventions_projection(&projection, &mut buf);
let mut offset = 0;
let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
assert_eq!(decoded, projection);
}
#[test]
fn unknown_mode_tag_is_rejected() {
let projection = sample();
let mut buf = Vec::new();
write_conventions_projection(&projection, &mut buf);
let first = &projection.entries[0];
let mode_byte_offset = 1 + 4 + (4 + first.name.len()) + (4 + first.pattern.len()) + 8;
assert_eq!(buf[mode_byte_offset], TAG_WRAP, "test fixture assumption");
buf[mode_byte_offset] = 0xFF;
let mut offset = 0;
let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
assert_eq!(err, DecodeError::InvalidConventionsProjectionTag(0xFF));
}
#[test]
fn unknown_attach_presence_tag_is_rejected() {
let projection = sample();
let mut buf = Vec::new();
write_conventions_projection(&projection, &mut buf);
let first = &projection.entries[0];
let mode_byte_offset = 1 + 4 + (4 + first.name.len()) + (4 + first.pattern.len()) + 8;
let attach_presence_offset = mode_byte_offset + 1;
assert_eq!(
buf[attach_presence_offset], 1,
"test fixture assumption: attach present"
);
buf[attach_presence_offset] = 0xFF;
let mut offset = 0;
let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
assert_eq!(err, DecodeError::InvalidConventionsProjectionTag(0xFF));
}
#[test]
fn unsupported_section_version_is_rejected() {
let mut buf = Vec::new();
write_conventions_projection(&ConventionsProjectionDef::default(), &mut buf);
buf[0] = CONVENTIONS_PROJECTION_WIRE_VERSION + 1;
let mut offset = 0;
let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
assert_eq!(
err,
DecodeError::UnsupportedSectionVersion {
section: 0,
version: CONVENTIONS_PROJECTION_WIRE_VERSION + 1,
}
);
}
}