Merge hands/union-derive-rs: cultcache-rs CultUnion derive held to the C# golden
Soul holds at cultlib-gaps:verdict:cut-union-derive-rs.s2; depth and remaining Lows move to union-bounds-rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4ea296ae10
4 changed files with 1145 additions and 32 deletions
|
|
@ -184,6 +184,49 @@ without pretending Rust has runtime reflection hiding under the floorboards.
|
|||
- `load_soa`
|
||||
- `SingleFileMessagePackBackingStore`
|
||||
|
||||
## Unions
|
||||
|
||||
A union that crosses runtimes is written `[armKey, [armSlots...]]`, the shape the C# reference writes for
|
||||
`[Union(key, type)]`. Derive `CultUnion` on an enum: every variant carries `#[cultcache(key = N)]` and is a unit
|
||||
(`[N, []]`) or a newtype over a `#[derive(CultSlots)]` struct, which is a `DatabaseEntry`'s slot array without the
|
||||
entry consts (a `String` or an `Option` is not an arm, and is a compile error). An arm key is the reference's Int32, negatives included, and is given once. A key the enum does not declare is a decode error naming the enum and the declared keys, and never
|
||||
quoting the payload. A null union is `Option<Enum>`. Plain serde enums are untouched and keep their bytes.
|
||||
|
||||
```rust
|
||||
use cultcache_rs::{CultSlots, CultUnion};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultUnion)]
|
||||
enum Node {
|
||||
#[cultcache(key = 0)]
|
||||
Box(BoxArm),
|
||||
#[cultcache(key = 1)]
|
||||
Text(TextArm),
|
||||
#[cultcache(key = 5)]
|
||||
Spacer, // a unit arm: [5, []]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct BoxArm {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
children: Vec<Node>, // recursive
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct TextArm {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
content: String,
|
||||
}
|
||||
|
||||
let tree = Node::Box(BoxArm { id: "root".into(), children: vec![Node::Spacer] });
|
||||
let bytes = rmp_serde::to_vec(&tree)?;
|
||||
assert_eq!(rmp_serde::from_slice::<Node>(&bytes)?, tree);
|
||||
# Ok::<(), Box<dyn std::error::Error>>(())
|
||||
```
|
||||
|
||||
## Structure Of Arrays
|
||||
|
||||
The C# CultCache hot path exposes CPU-local SoA scans as:
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
{
|
||||
let parse_result = attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("type") {
|
||||
once(&entry_type, &meta, "type")?;
|
||||
entry_type = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("schema") {
|
||||
once(&schema_name, &meta, "schema")?;
|
||||
schema_name = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
@ -47,31 +49,79 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
};
|
||||
let schema_name = schema_name.unwrap_or_else(|| LitStr::new(&ident.to_string(), ident.span()));
|
||||
|
||||
let fields = match input.data {
|
||||
let slots = match named_slots(input.data, &ident, "DatabaseEntry") {
|
||||
Ok(slots) => slots,
|
||||
Err(error) => return error.to_compile_error().into(),
|
||||
};
|
||||
let codec = slot_codec(&ident, &slots);
|
||||
|
||||
quote! {
|
||||
impl ::cultcache_rs::DatabaseEntry for #ident {
|
||||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
}
|
||||
|
||||
#codec
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A named struct whose fields carry `#[cultcache(key = N)]`: the same positional
|
||||
/// array a `DatabaseEntry` is, without the entry consts. The payload of a union arm,
|
||||
/// or any nested record that crosses runtimes.
|
||||
#[proc_macro_derive(CultSlots, attributes(cultcache))]
|
||||
pub fn derive_cult_slots(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let ident = input.ident.clone();
|
||||
if !input.generics.params.is_empty() {
|
||||
return syn::Error::new_spanned(ident, "CultSlots derive does not support generics")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
match named_slots(input.data, &ident, "CultSlots") {
|
||||
Ok(slots) => slot_codec(&ident, &slots).into(),
|
||||
Err(error) => error.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum written as `[key, armSlots]`. Every variant carries `#[cultcache(key = N)]`
|
||||
/// and is a unit (`[N, []]`) or a newtype over a type that serializes as an array.
|
||||
/// A key the enum does not declare is a decode error; there is no catch-all arm.
|
||||
#[proc_macro_derive(CultUnion, attributes(cultcache))]
|
||||
pub fn derive_cult_union(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
match union_codec(input) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(error) => error.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<CultCacheSlot>> {
|
||||
let fields = match data {
|
||||
Data::Struct(data) => match data.fields {
|
||||
Fields::Named(fields) => fields.named,
|
||||
_ => {
|
||||
return syn::Error::new_spanned(
|
||||
return Err(syn::Error::new_spanned(
|
||||
ident,
|
||||
"DatabaseEntry derive requires a named struct",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
format!("{owner} derive requires a named struct"),
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return syn::Error::new_spanned(ident, "DatabaseEntry derive requires a struct")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
return Err(syn::Error::new_spanned(
|
||||
ident,
|
||||
format!("{owner} derive requires a struct"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut slots: Vec<CultCacheSlot> = Vec::new();
|
||||
for field in fields {
|
||||
let Some(field_ident) = field.ident else {
|
||||
return syn::Error::new_spanned(field.ty, "DatabaseEntry derive requires named fields")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
return Err(syn::Error::new_spanned(
|
||||
field.ty,
|
||||
format!("{owner} derive requires named fields"),
|
||||
));
|
||||
};
|
||||
let mut key: Option<usize> = None;
|
||||
let mut default = false;
|
||||
|
|
@ -81,8 +131,9 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("cultcache"))
|
||||
{
|
||||
let parse_result = attribute.parse_nested_meta(|meta| {
|
||||
attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("key") {
|
||||
once(&key, &meta, "key")?;
|
||||
let value: LitInt = meta.value()?.parse()?;
|
||||
key = Some(value.base10_parse()?);
|
||||
Ok(())
|
||||
|
|
@ -95,26 +146,19 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
} else {
|
||||
Err(meta.error("unsupported field cultcache attribute"))
|
||||
}
|
||||
});
|
||||
if let Err(error) = parse_result {
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let Some(key) = key else {
|
||||
return syn::Error::new_spanned(
|
||||
return Err(syn::Error::new_spanned(
|
||||
field_ident,
|
||||
"DatabaseEntry fields require #[cultcache(key = N)]",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
format!("{owner} fields require #[cultcache(key = N)]"),
|
||||
));
|
||||
};
|
||||
if slots.iter().any(|slot| slot.key == key) {
|
||||
return syn::Error::new_spanned(
|
||||
return Err(syn::Error::new_spanned(
|
||||
field_ident,
|
||||
format!("duplicate DatabaseEntry slot {key}"),
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
format!("duplicate {owner} slot {key}"),
|
||||
));
|
||||
}
|
||||
slots.push(CultCacheSlot {
|
||||
key,
|
||||
|
|
@ -125,6 +169,12 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
});
|
||||
}
|
||||
slots.sort_by_key(|slot| slot.key);
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
/// The one slot generator: Serialize as a positional array with nil gaps, Deserialize
|
||||
/// by slot with the `default` and `bytes` attributes.
|
||||
fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::TokenStream {
|
||||
let max_slot = slots.iter().map(|slot| slot.key).max().unwrap_or(0);
|
||||
|
||||
let serialize_elements = (0..=max_slot).map(|slot| {
|
||||
|
|
@ -211,10 +261,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
});
|
||||
|
||||
quote! {
|
||||
impl ::cultcache_rs::DatabaseEntry for #ident {
|
||||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
}
|
||||
impl ::cultcache_rs::SlotRecord for #ident {}
|
||||
|
||||
impl ::serde::Serialize for #ident {
|
||||
fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
|
||||
|
|
@ -266,7 +313,31 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// An attribute names each thing once; a second `key = N` would silently win otherwise.
|
||||
fn once<T>(seen: &Option<T>, meta: &syn::meta::ParseNestedMeta, name: &str) -> syn::Result<()> {
|
||||
if seen.is_some() {
|
||||
return Err(meta.error(format!("repeated `{name}`: #[cultcache({name} = ...)] is given once")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An arm key is the reference's Int32, negatives included: `key = 5` or `key = -1`.
|
||||
fn arm_key(meta: &syn::meta::ParseNestedMeta) -> syn::Result<i32> {
|
||||
let expr: syn::Expr = meta.value()?.parse()?;
|
||||
let (negative, literal) = match expr {
|
||||
syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(literal), .. }) => (false, literal),
|
||||
syn::Expr::Unary(syn::ExprUnary { op: syn::UnOp::Neg(_), expr, .. }) => match *expr {
|
||||
syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(literal), .. }) => (true, literal),
|
||||
other => return Err(syn::Error::new_spanned(other, "a CultUnion arm key is an integer literal")),
|
||||
},
|
||||
other => return Err(syn::Error::new_spanned(other, "a CultUnion arm key is an integer literal")),
|
||||
};
|
||||
let magnitude: i128 = literal.base10_parse()?;
|
||||
let value = if negative { -magnitude } else { magnitude };
|
||||
i32::try_from(value)
|
||||
.map_err(|_| syn::Error::new_spanned(&literal, format!("CultUnion arm key {value} is outside Int32")))
|
||||
}
|
||||
|
||||
struct CultCacheSlot {
|
||||
|
|
@ -276,3 +347,395 @@ struct CultCacheSlot {
|
|||
default: bool,
|
||||
bytes: bool,
|
||||
}
|
||||
|
||||
fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||
let ident = input.ident.clone();
|
||||
if !input.generics.params.is_empty() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
ident,
|
||||
"CultUnion derive does not support generics",
|
||||
));
|
||||
}
|
||||
let Data::Enum(data) = input.data else {
|
||||
return Err(syn::Error::new_spanned(ident, "CultUnion derive requires an enum"));
|
||||
};
|
||||
if data.variants.is_empty() {
|
||||
return Err(syn::Error::new_spanned(ident, "CultUnion derive requires at least one variant"));
|
||||
}
|
||||
|
||||
struct Arm {
|
||||
key: i32,
|
||||
ident: syn::Ident,
|
||||
payload: Option<Type>,
|
||||
}
|
||||
let mut arms: Vec<Arm> = Vec::new();
|
||||
for variant in data.variants {
|
||||
let mut key: Option<i32> = None;
|
||||
for attribute in variant
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("cultcache"))
|
||||
{
|
||||
attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("key") {
|
||||
once(&key, &meta, "key")?;
|
||||
key = Some(arm_key(&meta)?);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported variant cultcache attribute"))
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let Some(key) = key else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&variant.ident,
|
||||
"CultUnion variants require #[cultcache(key = N)]",
|
||||
));
|
||||
};
|
||||
if arms.iter().any(|arm| arm.key == key) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&variant.ident,
|
||||
format!("duplicate CultUnion key {key}"),
|
||||
));
|
||||
}
|
||||
let payload = match variant.fields {
|
||||
Fields::Unit => None,
|
||||
Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
|
||||
Some(fields.unnamed.into_iter().next().expect("one field").ty)
|
||||
}
|
||||
_ => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&variant.ident,
|
||||
"a CultUnion variant is a unit or a newtype: use a newtype over a #[derive(CultSlots)] struct",
|
||||
));
|
||||
}
|
||||
};
|
||||
arms.push(Arm {
|
||||
key,
|
||||
ident: variant.ident,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
let enum_name = ident.to_string();
|
||||
let mut declared_keys: Vec<i32> = arms.iter().map(|arm| arm.key).collect();
|
||||
declared_keys.sort_unstable();
|
||||
let declared = declared_keys.iter().map(i32::to_string).collect::<Vec<_>>().join(", ");
|
||||
let unknown_message = format!("unknown arm key {{}} for union {enum_name}; declared keys: {declared}");
|
||||
let arity_message = format!("union {enum_name} is not a [key, slots] pair");
|
||||
let non_integer_message = format!("union {enum_name} has a non-integer arm key; declared keys: {declared}");
|
||||
let out_of_range_message = format!("union {enum_name} has an arm key outside Int32; declared keys: {declared}");
|
||||
let payload_checks = arms.iter().filter_map(|arm| arm.payload.as_ref()).map(|ty| {
|
||||
quote! { assert_slot_record::<#ty>(); }
|
||||
});
|
||||
|
||||
let serialize_arms = arms.iter().map(|arm| {
|
||||
let variant = &arm.ident;
|
||||
let key = arm.key;
|
||||
if arm.payload.is_some() {
|
||||
quote! {
|
||||
Self::#variant(arm) => {
|
||||
tuple.serialize_element(&#key)?;
|
||||
tuple.serialize_element(arm)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
Self::#variant => {
|
||||
tuple.serialize_element(&#key)?;
|
||||
tuple.serialize_element(&[(); 0])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let deserialize_arms = arms.iter().map(|arm| {
|
||||
let variant = &arm.ident;
|
||||
let key = arm.key;
|
||||
if let Some(ty) = &arm.payload {
|
||||
quote! {
|
||||
#key => {
|
||||
let arm = seq
|
||||
.next_element::<#ty>()?
|
||||
.ok_or_else(|| ::serde::de::Error::custom(#arity_message))?;
|
||||
#ident::#variant(arm)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#key => {
|
||||
seq.next_element::<[(); 0]>()
|
||||
.map_err(|_| ::serde::de::Error::custom(#arity_message))?
|
||||
.ok_or_else(|| ::serde::de::Error::custom(#arity_message))?;
|
||||
#ident::#variant
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(quote! {
|
||||
// An arm is a slots array: a newtype over anything else is refused here, by the
|
||||
// trait the CultSlots and DatabaseEntry derives implement.
|
||||
const _: fn() = || {
|
||||
#[allow(dead_code)]
|
||||
fn assert_slot_record<T: ::cultcache_rs::SlotRecord>() {}
|
||||
#(#payload_checks)*
|
||||
};
|
||||
|
||||
impl ::serde::Serialize for #ident {
|
||||
fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: ::serde::Serializer,
|
||||
{
|
||||
use ::serde::ser::SerializeTuple;
|
||||
let mut tuple = serializer.serialize_tuple(2)?;
|
||||
match self {
|
||||
#(#serialize_arms)*
|
||||
}
|
||||
tuple.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> ::serde::Deserialize<'de> for #ident {
|
||||
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: ::serde::Deserializer<'de>,
|
||||
{
|
||||
// The reference classifies the key before the arm: an integer in Int32, an
|
||||
// integer outside it, or not an integer. No input value is kept or echoed.
|
||||
enum ArmKey {
|
||||
Int(i64),
|
||||
OutOfRange,
|
||||
NonInteger,
|
||||
}
|
||||
struct ArmKeyProbe(ArmKey);
|
||||
struct ArmKeyVisitor;
|
||||
|
||||
impl<'de> ::serde::de::Visitor<'de> for ArmKeyVisitor {
|
||||
type Value = ArmKeyProbe;
|
||||
|
||||
fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
|
||||
formatter.write_str("an integer arm key")
|
||||
}
|
||||
|
||||
fn visit_i64<E>(self, value: i64) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::Int(value)))
|
||||
}
|
||||
|
||||
fn visit_u64<E>(self, value: u64) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(match i64::try_from(value) {
|
||||
Ok(value) => ArmKey::Int(value),
|
||||
Err(_) => ArmKey::OutOfRange,
|
||||
}))
|
||||
}
|
||||
|
||||
fn visit_bool<E>(self, _: bool) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_f64<E>(self, _: f64) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, _: &str) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_bytes<E>(self, _: &[u8]) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_unit<E>(self) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_none<E>(self) -> ::std::result::Result<Self::Value, E> {
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_some<D>(self, deserializer: D) -> ::std::result::Result<Self::Value, D::Error>
|
||||
where
|
||||
D: ::serde::Deserializer<'de>,
|
||||
{
|
||||
<::serde::de::IgnoredAny as ::serde::Deserialize>::deserialize(deserializer)?;
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> ::std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: ::serde::de::SeqAccess<'de>,
|
||||
{
|
||||
while seq.next_element::<::serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> ::std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: ::serde::de::MapAccess<'de>,
|
||||
{
|
||||
while map.next_entry::<::serde::de::IgnoredAny, ::serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(ArmKeyProbe(ArmKey::NonInteger))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> ::serde::Deserialize<'de> for ArmKeyProbe {
|
||||
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: ::serde::Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_any(ArmKeyVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct CultUnionVisitor;
|
||||
|
||||
impl<'de> ::serde::de::Visitor<'de> for CultUnionVisitor {
|
||||
type Value = #ident;
|
||||
|
||||
fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
|
||||
formatter.write_str(concat!("a [key, slots] pair for union ", stringify!(#ident)))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> ::std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: ::serde::de::SeqAccess<'de>,
|
||||
{
|
||||
if seq.size_hint().is_some_and(|len| len != 2) {
|
||||
return ::std::result::Result::Err(::serde::de::Error::custom(#arity_message));
|
||||
}
|
||||
let key = match seq
|
||||
.next_element::<ArmKeyProbe>()
|
||||
.map_err(|_| ::serde::de::Error::custom(#arity_message))?
|
||||
.ok_or_else(|| ::serde::de::Error::custom(#arity_message))?
|
||||
.0
|
||||
{
|
||||
ArmKey::Int(wide) => match i32::try_from(wide) {
|
||||
Ok(key) => key,
|
||||
Err(_) => {
|
||||
return ::std::result::Result::Err(::serde::de::Error::custom(#out_of_range_message));
|
||||
}
|
||||
},
|
||||
ArmKey::OutOfRange => {
|
||||
return ::std::result::Result::Err(::serde::de::Error::custom(#out_of_range_message));
|
||||
}
|
||||
ArmKey::NonInteger => {
|
||||
return ::std::result::Result::Err(::serde::de::Error::custom(#non_integer_message));
|
||||
}
|
||||
};
|
||||
let value = match key {
|
||||
#(#deserialize_arms)*
|
||||
other => {
|
||||
return ::std::result::Result::Err(::serde::de::Error::custom(
|
||||
format!(#unknown_message, other),
|
||||
));
|
||||
}
|
||||
};
|
||||
if seq.next_element::<::serde::de::IgnoredAny>()?.is_some() {
|
||||
return ::std::result::Result::Err(::serde::de::Error::custom(#arity_message));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_tuple(2, CultUnionVisitor)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn union_error(source: &str) -> String {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
union_codec(input).expect_err("the fixture is refused").to_string()
|
||||
}
|
||||
|
||||
fn slots_error(source: &str) -> String {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
let Err(error) = named_slots(input.data, &input.ident, "CultSlots") else {
|
||||
panic!("the fixture is refused");
|
||||
};
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
fn union_ok(source: &str) {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
union_codec(input).expect("the fixture is accepted");
|
||||
}
|
||||
|
||||
// Keys other than 0 throughout, so a default of 0 cannot stand in for a missing key.
|
||||
#[test]
|
||||
fn a_variant_without_a_key_is_refused_for_that_reason() {
|
||||
let message = union_error("enum E { #[cultcache(key = 4)] A(Arm), B }");
|
||||
assert!(message.contains("require #[cultcache(key = N)]"), "{message}");
|
||||
let message = union_error("enum E { B, #[cultcache(key = 4)] A(Arm) }");
|
||||
assert!(message.contains("require #[cultcache(key = N)]"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_variants_with_one_key_are_refused_for_that_reason() {
|
||||
let message = union_error("enum E { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 4)] B }");
|
||||
assert!(message.contains("duplicate CultUnion key 4"), "{message}");
|
||||
let message = union_error("enum E { #[cultcache(key = 0x4)] A(Arm), #[cultcache(key = 4)] B }");
|
||||
assert!(message.contains("duplicate CultUnion key 4"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_key_attribute_is_refused_on_a_variant_and_on_a_field() {
|
||||
for source in [
|
||||
"enum E { #[cultcache(key = 7)] #[cultcache(key = 0)] A(Arm) }",
|
||||
"enum E { #[cultcache(key = 7, key = 0)] A(Arm) }",
|
||||
] {
|
||||
let message = union_error(source);
|
||||
assert!(message.contains("repeated `key`"), "{source}: {message}");
|
||||
}
|
||||
for source in [
|
||||
"struct S { #[cultcache(key = 7)] #[cultcache(key = 0)] a: String }",
|
||||
"struct S { #[cultcache(key = 7, key = 0)] a: String }",
|
||||
] {
|
||||
let message = slots_error(source);
|
||||
assert!(message.contains("repeated `key`"), "{source}: {message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_slot_is_refused_for_that_reason() {
|
||||
let message = slots_error("struct S { #[cultcache(key = 3)] a: String, #[cultcache(key = 3)] b: String }");
|
||||
assert!(message.contains("duplicate CultSlots slot 3"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_without_a_key_is_refused_for_that_reason() {
|
||||
let message = slots_error("struct S { #[cultcache(key = 3)] a: String, b: String }");
|
||||
assert!(message.contains("fields require #[cultcache(key = N)]"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_multi_field_and_generic_variants_are_refused_for_their_reasons() {
|
||||
let message = union_error("enum E { #[cultcache(key = 4)] A { id: String } }");
|
||||
assert!(message.contains("use a newtype over a #[derive(CultSlots)] struct"), "{message}");
|
||||
let message = union_error("enum E { #[cultcache(key = 4)] A(Arm, Arm) }");
|
||||
assert!(message.contains("use a newtype over a #[derive(CultSlots)] struct"), "{message}");
|
||||
let message = union_error("enum E<T> { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 5)] B(T) }");
|
||||
assert!(message.contains("does not support generics"), "{message}");
|
||||
}
|
||||
|
||||
// The arm key domain is the reference's Int32: negatives declare, Int32::MAX declares,
|
||||
// one past it is a compile error.
|
||||
#[test]
|
||||
fn the_arm_key_domain_is_int32() {
|
||||
union_ok("enum E { #[cultcache(key = -1)] A(Arm), #[cultcache(key = 2147483647)] B, #[cultcache(key = -2147483648)] C }");
|
||||
for source in [
|
||||
"enum E { #[cultcache(key = 2147483648)] A(Arm) }",
|
||||
"enum E { #[cultcache(key = -2147483649)] A(Arm) }",
|
||||
"enum E { #[cultcache(key = 4294967296)] A(Arm) }",
|
||||
] {
|
||||
let message = union_error(source);
|
||||
assert!(message.contains("outside Int32"), "{source}: {message}");
|
||||
}
|
||||
let message = union_error("enum E { #[cultcache(key = \"1\")] A(Arm) }");
|
||||
assert!(message.contains("integer literal"), "{message}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,130 @@
|
|||
//! The derives refuse a malformed union at compile time. Each block below is a valid
|
||||
//! union with one property changed; keys are never 0, so a refusal cannot be a default
|
||||
//! key colliding with a declared 0. The derive crate's unit tests assert the message of
|
||||
//! every refusal the derive itself makes; a `compile_fail` block shows only that it fails.
|
||||
//!
|
||||
//! ```
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Fine { #[cultcache(key = -1)] A(Arm), #[cultcache(key = 2147483647)] B, #[cultcache(key = 4)] C }
|
||||
//! ```
|
||||
//!
|
||||
//! A variant without a key, first and last:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] A(Arm), B }
|
||||
//! ```
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { B, #[cultcache(key = 4)] A(Arm) }
|
||||
//! ```
|
||||
//!
|
||||
//! Two variants with one key:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 4)] B }
|
||||
//! ```
|
||||
//!
|
||||
//! A key attribute given twice:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultUnion;
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] #[cultcache(key = 5)] A }
|
||||
//! ```
|
||||
//!
|
||||
//! A key outside Int32:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultUnion;
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 2147483648)] A }
|
||||
//! ```
|
||||
//!
|
||||
//! A struct variant:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultUnion;
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] A { id: String }, #[cultcache(key = 5)] B }
|
||||
//! ```
|
||||
//!
|
||||
//! A multi-field tuple variant:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] A(Arm, Arm), #[cultcache(key = 5)] B }
|
||||
//! ```
|
||||
//!
|
||||
//! A generic enum:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad<T> { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 5)] B(std::marker::PhantomData<T>) }
|
||||
//! ```
|
||||
//!
|
||||
//! An arm that is not a slots record (rustc's `SlotRecord` bound, not the derive, refuses it):
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultUnion;
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] A(String) }
|
||||
//! ```
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::{CultSlots, CultUnion};
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String }
|
||||
//! #[derive(CultUnion)]
|
||||
//! enum Bad { #[cultcache(key = 4)] A(Option<Arm>) }
|
||||
//! ```
|
||||
//!
|
||||
//! A CultSlots field without a key, a repeated slot, a repeated key attribute, and a generic struct:
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultSlots;
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String, name: String }
|
||||
//! ```
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultSlots;
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] id: String, #[cultcache(key = 3)] name: String }
|
||||
//! ```
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultSlots;
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm { #[cultcache(key = 3)] #[cultcache(key = 4)] id: String }
|
||||
//! ```
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use cultcache_rs::CultSlots;
|
||||
//! #[derive(CultSlots)]
|
||||
//! struct Arm<T> { #[cultcache(key = 3)] id: T }
|
||||
//! ```
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
|
|
@ -20,6 +147,8 @@ use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
|
|||
|
||||
extern crate self as cultcache_rs;
|
||||
|
||||
pub use cultcache_rs_derive::CultSlots;
|
||||
pub use cultcache_rs_derive::CultUnion;
|
||||
pub use cultcache_rs_derive::DatabaseEntry;
|
||||
|
||||
pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
|
||||
|
|
@ -27,6 +156,15 @@ pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
|
|||
const SCHEMA_NAME: &'static str = "DatabaseEntry";
|
||||
}
|
||||
|
||||
/// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry`
|
||||
/// derives implement, and what a `CultUnion` arm must carry.
|
||||
#[diagnostic::on_unimplemented(
|
||||
message = "`{Self}` is not a slots record",
|
||||
label = "a CultUnion arm is a newtype over a #[derive(CultSlots)] struct",
|
||||
note = "a String, an Option or any other type is not written as an array of slots"
|
||||
)]
|
||||
pub trait SlotRecord {}
|
||||
|
||||
pub trait CultCacheRegistry {
|
||||
fn register_entries(&self, cache: &mut CultCache) -> Result<()>;
|
||||
}
|
||||
|
|
|
|||
469
packages/cultcache-rs/tests/union_parity.rs
Normal file
469
packages/cultcache-rs/tests/union_parity.rs
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
//! The Rust runtime held to tests/vectors/union-parity, written by the C# reference.
|
||||
//! Bytes and outcomes are read from the committed files; only the constructed value is spelled here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::CultSlots;
|
||||
use cultcache_rs::CultUnion;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultUnion)]
|
||||
enum Node {
|
||||
#[cultcache(key = 0)]
|
||||
Box(BoxArm),
|
||||
#[cultcache(key = 1)]
|
||||
Text(TextArm),
|
||||
#[cultcache(key = 2)]
|
||||
Use(UseArm),
|
||||
#[cultcache(key = 5)]
|
||||
Image(ImageArm),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct BoxArm {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
children: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct TextArm {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct UseArm {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
composition: Composition,
|
||||
#[cultcache(key = 2)]
|
||||
slots: Vec<Option<Node>>,
|
||||
}
|
||||
|
||||
// Slot 2 is retired: a gap written nil.
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct ImageArm {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
uri: String,
|
||||
#[cultcache(key = 3)]
|
||||
alt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct Composition {
|
||||
#[cultcache(key = 0)]
|
||||
name: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: Option<std::boxed::Box<Node>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultUnion)]
|
||||
enum Outcome {
|
||||
#[cultcache(key = 0)]
|
||||
Pending(PendingArm),
|
||||
#[cultcache(key = 1)]
|
||||
Completed(CompletedArm),
|
||||
#[cultcache(key = 2)]
|
||||
Denied,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct PendingArm {
|
||||
#[cultcache(key = 0)]
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct CompletedArm {
|
||||
#[cultcache(key = 0)]
|
||||
source_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct NamedArm {
|
||||
#[cultcache(key = 0)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct UsesArm {
|
||||
#[cultcache(key = 0)]
|
||||
name: String,
|
||||
#[cultcache(key = 1)]
|
||||
uses: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct CountArm {
|
||||
#[cultcache(key = 0)]
|
||||
name: String,
|
||||
#[cultcache(key = 1)]
|
||||
count: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultUnion)]
|
||||
enum Item {
|
||||
#[cultcache(key = 0)]
|
||||
Gem(NamedArm),
|
||||
#[cultcache(key = 1)]
|
||||
Tool(UsesArm),
|
||||
#[cultcache(key = 2)]
|
||||
Part(CountArm),
|
||||
}
|
||||
|
||||
// The same arms as Item under its own keys: key 2 is unknown to it.
|
||||
#[derive(Clone, Debug, PartialEq, CultUnion)]
|
||||
enum CraftedItem {
|
||||
#[cultcache(key = 0)]
|
||||
Tool(UsesArm),
|
||||
#[cultcache(key = 1)]
|
||||
Part(CountArm),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(type = "tests.union_surface", schema = "tests.union_surface.v1")]
|
||||
struct UnionSurface {
|
||||
#[cultcache(key = 0)]
|
||||
id: String,
|
||||
#[cultcache(key = 1)]
|
||||
version: i32,
|
||||
#[cultcache(key = 2)]
|
||||
root: Option<Node>,
|
||||
#[cultcache(key = 3)]
|
||||
outcomes: Vec<Outcome>,
|
||||
#[cultcache(key = 4)]
|
||||
part: Option<Item>,
|
||||
#[cultcache(key = 5)]
|
||||
crafted: Option<CraftedItem>,
|
||||
#[cultcache(key = 6)]
|
||||
missing: Option<Node>,
|
||||
}
|
||||
|
||||
fn text(id: &str, content: &str) -> Node {
|
||||
Node::Text(TextArm { id: id.into(), content: content.into() })
|
||||
}
|
||||
|
||||
fn image(id: &str, uri: &str, alt: &str) -> Node {
|
||||
Node::Image(ImageArm { id: id.into(), uri: uri.into(), alt: alt.into() })
|
||||
}
|
||||
|
||||
fn boxed(id: &str, children: Vec<Node>) -> Node {
|
||||
Node::Box(BoxArm { id: id.into(), children })
|
||||
}
|
||||
|
||||
fn constructed() -> UnionSurface {
|
||||
let inner = boxed("inner", vec![text("deep", "x"), image("img", "uri://a", "alt")]);
|
||||
UnionSurface {
|
||||
id: "surface".into(),
|
||||
version: 3,
|
||||
root: Some(boxed(
|
||||
"root",
|
||||
vec![
|
||||
text("t1", "hello"),
|
||||
Node::Use(UseArm {
|
||||
id: "u1".into(),
|
||||
composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) },
|
||||
slots: vec![Some(text("s1", "slot")), None, Some(boxed("b2", vec![]))],
|
||||
}),
|
||||
image("i2", "uri://b", ""),
|
||||
],
|
||||
)),
|
||||
outcomes: vec![
|
||||
Outcome::Pending(PendingArm { message: "wait".into() }),
|
||||
Outcome::Completed(CompletedArm { source_version: 7 }),
|
||||
Outcome::Denied,
|
||||
],
|
||||
part: Some(Item::Tool(UsesArm { name: "hammer".into(), uses: 12 })),
|
||||
crafted: Some(CraftedItem::Part(CountArm { name: "gear".into(), count: 4 })),
|
||||
missing: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn vector(name: &str) -> Vec<u8> {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/vectors/union-parity")
|
||||
.join(name);
|
||||
std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Result<UnionSurface, String> {
|
||||
rmp_serde::from_slice(bytes).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_surface_decodes_and_reencodes() {
|
||||
let payload = vector("union-surface.msgpack");
|
||||
let value = constructed();
|
||||
assert_eq!(decode(&payload).unwrap(), value);
|
||||
assert_eq!(rmp_serde::to_vec(&value).unwrap(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unit_arm_is_a_key_and_an_empty_slots_array() {
|
||||
assert_eq!(rmp_serde::to_vec(&Outcome::Denied).unwrap(), [0x92, 0x02, 0x90]);
|
||||
assert_eq!(rmp_serde::from_slice::<Outcome>(&[0x92, 0x02, 0x90]).unwrap(), Outcome::Denied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_refusal_vectors() {
|
||||
let outcomes: Vec<(String, String, String, Option<u64>, String)> =
|
||||
rmp_serde::from_slice(&vector("outcomes.msgpack")).unwrap();
|
||||
let (mut refused, mut read_only) = (0, 0);
|
||||
for (file, outcome, union, key, detail) in outcomes {
|
||||
let result = decode(&vector(&file));
|
||||
match outcome.as_str() {
|
||||
"refused" => {
|
||||
refused += 1;
|
||||
let message = result.expect_err(&file);
|
||||
assert!(message.contains(&union), "{file}: {message}");
|
||||
if detail == "unknown-arm" {
|
||||
let key = key.expect(&file);
|
||||
assert!(message.contains(&format!("unknown arm key {key} ")), "{file}: {message}");
|
||||
}
|
||||
for canary in ["root", "hello", "uri://", "surface"] {
|
||||
assert!(!message.contains(canary), "{file} echoed a value: {message}");
|
||||
}
|
||||
}
|
||||
"read-only" => {
|
||||
read_only += 1;
|
||||
assert_eq!(result.expect(&file), constructed(), "{file}");
|
||||
}
|
||||
other => panic!("{file}: unknown outcome {other}"),
|
||||
}
|
||||
}
|
||||
assert_eq!((refused, read_only), (5, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_arm_lists_the_declared_keys_and_never_echoes_the_payload() {
|
||||
// Shaped like a real arm, an array of strings, under an undeclared key.
|
||||
let bytes = rmp_serde::to_vec(&(99u64, ("CANARY-7f3a", "CANARY-91bc"))).unwrap();
|
||||
let message = rmp_serde::from_slice::<Node>(&bytes).unwrap_err().to_string();
|
||||
assert!(message.contains("Node") && message.contains("99") && message.contains("0, 1, 2, 5"), "{message}");
|
||||
assert!(!message.contains("CANARY"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_union_that_is_not_a_key_and_slots_pair_is_refused_naming_the_enum() {
|
||||
let cases: [(&str, Vec<u8>); 4] = [
|
||||
("an empty array", vec![0x90]),
|
||||
("a trailing element", rmp_serde::to_vec(&(1u64, (7u8,), 0u8)).unwrap()),
|
||||
("a string key", rmp_serde::to_vec(&("CANARY-key", (7u8,))).unwrap()),
|
||||
("a unit arm with slots", rmp_serde::to_vec(&(2u64, ("CANARY-slot",))).unwrap()),
|
||||
];
|
||||
for (label, bytes) in cases {
|
||||
let message = rmp_serde::from_slice::<Outcome>(&bytes).unwrap_err().to_string();
|
||||
assert!(message.contains("Outcome"), "{label}: {message}");
|
||||
assert!(!message.contains("CANARY"), "{label}: {message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_decides_the_arm() {
|
||||
// Tool and Part carry different slots under different keys in the two unions.
|
||||
let bytes = rmp_serde::to_vec(&(1u64, ("hammer", 3u8))).unwrap();
|
||||
assert_eq!(
|
||||
rmp_serde::from_slice::<Item>(&bytes).unwrap(),
|
||||
Item::Tool(UsesArm { name: "hammer".into(), uses: 3 })
|
||||
);
|
||||
assert_eq!(
|
||||
rmp_serde::from_slice::<CraftedItem>(&bytes).unwrap(),
|
||||
CraftedItem::Part(CountArm { name: "hammer".into(), count: 3 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_store_reads() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("union-surface.cc");
|
||||
std::fs::write(&path, vector("union-surface.cc")).unwrap();
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<UnionSurface>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
assert_eq!(cache.get::<UnionSurface>("surface").unwrap(), Some(constructed()));
|
||||
}
|
||||
|
||||
// The arm key domain is the reference's Int32. Expected bytes are what MessagePack-CSharp writes
|
||||
// for the same keys (WriteInt32: fixneg, uint32, int32); the refusal classes are CultUnionGuard's.
|
||||
mod arm_keys {
|
||||
use cultcache_rs::CultSlots;
|
||||
use cultcache_rs::CultUnion;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultSlots)]
|
||||
struct Count {
|
||||
#[cultcache(key = 0)]
|
||||
name: String,
|
||||
#[cultcache(key = 1)]
|
||||
count: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, CultUnion)]
|
||||
enum Keyed {
|
||||
#[cultcache(key = -1)]
|
||||
Negative(Count),
|
||||
#[cultcache(key = 2147483647)]
|
||||
Max(Count),
|
||||
#[cultcache(key = -2147483648)]
|
||||
Min,
|
||||
#[cultcache(key = 7)]
|
||||
Plain(Count),
|
||||
}
|
||||
|
||||
fn count() -> Count {
|
||||
Count { name: "n".into(), count: 1 }
|
||||
}
|
||||
|
||||
fn message(bytes: &[u8]) -> String {
|
||||
rmp_serde::from_slice::<Keyed>(bytes).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_int32_max_keys_are_declared_and_round_trip() {
|
||||
let cases: [(Keyed, &[u8]); 4] = [
|
||||
(Keyed::Negative(count()), &[0x92, 0xff, 0x92, 0xa1, b'n', 0x01]),
|
||||
(Keyed::Max(count()), &[0x92, 0xce, 0x7f, 0xff, 0xff, 0xff, 0x92, 0xa1, b'n', 0x01]),
|
||||
(Keyed::Min, &[0x92, 0xd2, 0x80, 0x00, 0x00, 0x00, 0x90]),
|
||||
(Keyed::Plain(count()), &[0x92, 0x07, 0x92, 0xa1, b'n', 0x01]),
|
||||
];
|
||||
for (value, bytes) in cases {
|
||||
assert_eq!(rmp_serde::to_vec(&value).unwrap(), bytes, "{value:?}");
|
||||
assert_eq!(rmp_serde::from_slice::<Keyed>(bytes).unwrap(), value);
|
||||
}
|
||||
}
|
||||
|
||||
// Every integer width of a negative key reads as the same arm.
|
||||
#[test]
|
||||
fn a_negative_key_reads_in_every_integer_width() {
|
||||
for key in [&[0xff][..], &[0xd0, 0xff], &[0xd1, 0xff, 0xff], &[0xd2, 0xff, 0xff, 0xff, 0xff], &[0xd3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]] {
|
||||
let mut bytes = vec![0x92];
|
||||
bytes.extend_from_slice(key);
|
||||
bytes.extend_from_slice(&[0x92, 0xa1, b'n', 0x01]);
|
||||
assert_eq!(rmp_serde::from_slice::<Keyed>(&bytes).unwrap(), Keyed::Negative(count()), "{key:02x?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn pair(key: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = vec![0x92];
|
||||
bytes.extend_from_slice(key);
|
||||
bytes.extend_from_slice(&[0x92, 0xa1, b'C', 0xa1, b'A']);
|
||||
bytes
|
||||
}
|
||||
|
||||
// A negative key the union does not declare is an unknown arm naming the key, as in the reference.
|
||||
#[test]
|
||||
fn a_negative_undeclared_key_is_an_unknown_arm_naming_the_key() {
|
||||
for (key, shown) in [(&[0xe0][..], "-32"), (&[0xd0, 0xdf], "-33"), (&[0xd1, 0x80, 0x00], "-32768"), (&[0xd2, 0x80, 0x00, 0x00, 0x01], "-2147483647")] {
|
||||
let text = message(&pair(key));
|
||||
assert!(text.contains(&format!("unknown arm key {shown} ")), "{text}");
|
||||
assert!(text.contains("declared keys: -2147483648, -1, 7, 2147483647"), "{text}");
|
||||
assert!(!text.contains("CA"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
// Outside Int32 is its own class, naming neither the value nor the payload.
|
||||
#[test]
|
||||
fn keys_outside_int32_are_their_own_class_and_never_echoed() {
|
||||
let wide: [&[u8]; 6] = [
|
||||
&[0xce, 0x80, 0x00, 0x00, 0x00],
|
||||
&[0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00],
|
||||
&[0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
|
||||
&[0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff],
|
||||
&[0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
&[0xcf, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
|
||||
];
|
||||
for key in wide {
|
||||
let text = message(&pair(key));
|
||||
assert!(text.contains("an arm key outside Int32"), "{key:02x?}: {text}");
|
||||
assert!(text.contains("Keyed") && !text.contains("CA"), "{text}");
|
||||
for value in ["4294967296", "9223372036854775807", "9223372036854775808", "18446744073709551615"] {
|
||||
assert!(!text.contains(value), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_is_not_an_integer_is_its_own_class_and_never_echoed() {
|
||||
let keys: [&[u8]; 7] = [
|
||||
&[0xc0],
|
||||
&[0xc3],
|
||||
&[0xcb, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
&[0xa6, b'C', b'A', b'N', b'A', b'R', b'Y'],
|
||||
&[0xc4, 0x02, b'C', b'A'],
|
||||
&[0x91, 0xa6, b'C', b'A', b'N', b'A', b'R', b'Y'],
|
||||
&[0x81, 0xa6, b'C', b'A', b'N', b'A', b'R', b'Y', 0x01],
|
||||
];
|
||||
for key in keys {
|
||||
let text = message(&pair(key));
|
||||
assert!(text.contains("a non-integer arm key") && text.contains("Keyed"), "{key:02x?}: {text}");
|
||||
assert!(!text.contains("CANARY") && !text.contains("CA"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
// Anything that is not a two-element array is the arity class, whatever its first element is.
|
||||
#[test]
|
||||
fn a_pair_of_the_wrong_length_is_refused_before_its_key_is_read() {
|
||||
for bytes in [&[0x91, 0xa1, b'x'][..], &[0x93, 0x07, 0x90, 0x00], &[0x90]] {
|
||||
let text = message(bytes);
|
||||
assert!(text.contains("is not a [key, slots] pair"), "{bytes:02x?}: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod serde_enum_bytes {
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
enum External {
|
||||
Unit,
|
||||
Newtype(u32),
|
||||
Struct { n: u8 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
enum Internal {
|
||||
A { n: u8 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(type = "tests.plain_enums")]
|
||||
struct PlainEnums {
|
||||
#[cultcache(key = 0)]
|
||||
unit: External,
|
||||
#[cultcache(key = 1)]
|
||||
newtype: External,
|
||||
#[cultcache(key = 2)]
|
||||
structured: External,
|
||||
#[cultcache(key = 3)]
|
||||
tagged: Internal,
|
||||
}
|
||||
|
||||
// Computed with the derive and serde at b0f40577, before CultUnion existed.
|
||||
const AT_BASE: &[u8] = &[
|
||||
0x94, 0xa4, b'U', b'n', b'i', b't', 0x81, 0xa7, b'N', b'e', b'w', b't', b'y', b'p', b'e', 0x05, 0x81,
|
||||
0xa6, b'S', b't', b'r', b'u', b'c', b't', 0x91, 0x01, 0x92, 0xa1, b'A', 0x01,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn serde_enum_bytes_stable() {
|
||||
let value = PlainEnums {
|
||||
unit: External::Unit,
|
||||
newtype: External::Newtype(5),
|
||||
structured: External::Struct { n: 1 },
|
||||
tagged: Internal::A { n: 1 },
|
||||
};
|
||||
let bytes = rmp_serde::to_vec(&value).unwrap();
|
||||
assert_eq!(bytes, AT_BASE);
|
||||
assert_eq!(rmp_serde::from_slice::<PlainEnums>(AT_BASE).unwrap(), value);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue