From 2d840d5ffb702c4c19b1b836e196f1a2ab4402e8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:50:30 +0200 Subject: [PATCH] cultcache-rs: CultSlots and CultUnion derives writing the reference [key, armSlots] shape One slot generator now serves DatabaseEntry and CultSlots. CultUnion writes [key, slots] with unit arms as [key, []] and refuses unknown keys naming the enum and declared keys. Tests hold the runtime to tests/vectors/union-parity. Not yet built or run: this commit may not build. Co-Authored-By: Claude Opus 5.5 --- packages/cultcache-rs/README.md | 43 +++ .../cultcache-rs-derive/src/lib.rs | 287 +++++++++++++-- packages/cultcache-rs/src/lib.rs | 61 ++++ packages/cultcache-rs/tests/union_parity.rs | 342 ++++++++++++++++++ 4 files changed, 700 insertions(+), 33 deletions(-) create mode 100644 packages/cultcache-rs/tests/union_parity.rs diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 6841791f..c0aa02e0 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -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 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`. 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, // 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::(&bytes)?, tree); +# Ok::<(), Box>(()) +``` + ## Structure Of Arrays The C# CultCache hot path exposes CPU-local SoA scans as: diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index 7d613194..ec0abedc 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -47,31 +47,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> { + 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 = 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 = None; let mut default = false; @@ -81,7 +129,7 @@ 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") { let value: LitInt = meta.value()?.parse()?; key = Some(value.base10_parse()?); @@ -95,26 +143,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 +166,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,11 +258,6 @@ 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 ::serde::Serialize for #ident { fn serialize(&self, serializer: S) -> ::std::result::Result where @@ -266,7 +308,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { } } } - .into() } struct CultCacheSlot { @@ -276,3 +317,183 @@ struct CultCacheSlot { default: bool, bytes: bool, } + +fn union_codec(input: DeriveInput) -> syn::Result { + 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: u64, + ident: syn::Ident, + payload: Option, + } + let mut arms: Vec = Vec::new(); + for variant in data.variants { + let mut key: Option = 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") { + let value: LitInt = meta.value()?.parse()?; + key = Some(value.base10_parse()?); + 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 declared = arms + .iter() + .map(|arm| arm.key.to_string()) + .collect::>() + .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 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! { + impl ::serde::Serialize for #ident { + fn serialize(&self, serializer: S) -> ::std::result::Result + 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(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + 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(self, mut seq: A) -> ::std::result::Result + where + A: ::serde::de::SeqAccess<'de>, + { + let key = seq + .next_element::() + .map_err(|_| ::serde::de::Error::custom(#arity_message))? + .ok_or_else(|| ::serde::de::Error::custom(#arity_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) + } + } + }) +} diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 71bf8fca..be057e00 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1,3 +1,62 @@ +//! The derives refuse a malformed union at compile time. Each block below is a valid +//! union with one property changed. +//! +//! ``` +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 0)] id: String } +//! #[derive(CultUnion)] +//! enum Ok_ { #[cultcache(key = 0)] A(Arm), #[cultcache(key = 1)] B } +//! ``` +//! +//! A variant without a key: +//! +//! ```compile_fail +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 0)] id: String } +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 0)] A(Arm), B } +//! ``` +//! +//! Two variants with one key: +//! +//! ```compile_fail +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 0)] id: String } +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 0)] A(Arm), #[cultcache(key = 0)] B } +//! ``` +//! +//! A struct variant: +//! +//! ```compile_fail +//! use cultcache_rs::CultUnion; +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 0)] A { id: String }, #[cultcache(key = 1)] B } +//! ``` +//! +//! A multi-field tuple variant: +//! +//! ```compile_fail +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 0)] id: String } +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 0)] A(Arm, Arm), #[cultcache(key = 1)] B } +//! ``` +//! +//! A generic enum: +//! +//! ```compile_fail +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 0)] id: String } +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 0)] A(Arm), #[cultcache(key = 1)] B(std::marker::PhantomData) } +//! ``` + use anyhow::Context; use anyhow::Result; use anyhow::anyhow; @@ -20,6 +79,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 { diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs new file mode 100644 index 00000000..69d1764e --- /dev/null +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -0,0 +1,342 @@ +//! 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, +} + +#[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>, +} + +// 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, +} + +#[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, + #[cultcache(key = 3)] + outcomes: Vec, + #[cultcache(key = 4)] + part: Option, + #[cultcache(key = 5)] + crafted: Option, + #[cultcache(key = 6)] + missing: Option, +} + +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::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(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 { + 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 { + 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::(&[0x92, 0x02, 0x90]).unwrap(), Outcome::Denied); +} + +#[test] +fn union_refusal_vectors() { + let outcomes: Vec<(String, String, String, Option, 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::(&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); 4] = [ + ("an empty array", vec![0x90]), + ("a trailing element", rmp_serde::to_vec(&(1u64, ("a",), 0u8)).unwrap()), + ("a string key", rmp_serde::to_vec(&("CANARY-key", ("a",))).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::(&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::(&bytes).unwrap(), + Item::Tool(UsesArm { name: "hammer".into(), uses: 3 }) + ); + assert_eq!( + rmp_serde::from_slice::(&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::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + assert_eq!(cache.get::("surface").unwrap(), Some(constructed())); +} + +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::(AT_BASE).unwrap(), value); + } +}