From c4fae30677a21f8fd715e954c19aa2617d9e6d12 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 19:44:36 +0000 Subject: [PATCH 1/3] cultcache-rs: name, index and global lookups from the C# reference #[cultcache(name)], #[cultcache(index[ = alias])] and struct-level #[cultcache(global)] in the DatabaseEntry derive; get_by_name, get_by_index and get_global on CultCache; a second record of a global type under another key is a typed CultCacheSecondGlobal refusal on every write path and on load. Co-Authored-By: Claude Sonnet 5.5 --- .../cultcache-rs-derive/src/lib.rs | 163 +++++++++++++++++ packages/cultcache-rs/src/lib.rs | 165 ++++++++++++++++++ 2 files changed, 328 insertions(+) diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index 65e41b92..fe4e57c6 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -22,6 +22,7 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result = None; let mut schema_name: Option = None; + let mut global = false; for attribute in input .attrs @@ -37,6 +38,12 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result syn::Result ::std::option::Option<::std::string::String> { + ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) + } + } + }); + let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))); + let index_pushes = index_members.map(|(alias, member)| { + quote! { + if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) { + held.push((#alias, value)); + } + } + }); + let index_pushes: Vec<_> = index_pushes.collect(); + let indexes_fn = (!index_pushes.is_empty()).then(|| { + quote! { + fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> { + let mut held = ::std::vec::Vec::new(); + #(#index_pushes)* + held + } + } + }); Ok(quote! { impl ::cultcache_rs::DatabaseEntry for #ident { const TYPE: &'static str = #entry_type; const SCHEMA_NAME: &'static str = #schema_name; + const GLOBAL: bool = #global; + #name_fn + #indexes_fn } #codec @@ -133,6 +169,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result = None; let mut default = false; let mut bytes = false; + let mut name = false; + let mut index: Option = None; for attribute in field .attrs .iter() @@ -157,6 +195,22 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result()?.value() + } else { + String::new() + }; + index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias }); + Ok(()) } else { Err(meta.error("unsupported field cultcache attribute")) } @@ -180,8 +234,28 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result, } fn union_codec(input: DeriveInput) -> syn::Result { @@ -842,4 +920,89 @@ mod tests { let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted"); } + fn entry_error(source: &str) -> String { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + database_entry_codec(input).expect_err("the fixture is refused").to_string() + } + + fn entry_tokens(source: &str) -> String { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "") + } + + #[test] + fn name_index_and_global_generate_the_lookup_surface() { + let tokens = entry_tokens( + r#"#[cultcache(type = "t", global)] struct S { + #[cultcache(key = 0, name)] label: String, + #[cultcache(key = 1, index)] code: String, + #[cultcache(key = 2, index = "sku")] stock: Option, + }"#, + ); + assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}"); + assert!(tokens.contains("fncult_name"), "{tokens}"); + assert!(tokens.contains("(\"code\",value)"), "{tokens}"); + assert!(tokens.contains("(\"sku\",value)"), "{tokens}"); + } + + #[test] + fn a_record_without_lookups_generates_none_of_them() { + let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#); + assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}"); + assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}"); + } + + // ResolveIndexAlias: a blank alias is the member's name. + #[test] + fn a_blank_index_alias_is_the_member_name() { + let tokens = entry_tokens( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#, + ); + assert!(tokens.contains("(\"code\",value)"), "{tokens}"); + } + + #[test] + fn two_names_are_refused_for_that_reason() { + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#, + ); + assert!(message.contains("more than one member"), "{message}"); + } + + // D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it. + #[test] + fn two_members_with_one_index_alias_are_refused_for_that_reason() { + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#, + ); + assert!(message.contains("both declare index alias \"k\""), "{message}"); + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#, + ); + assert!(message.contains("both declare index alias \"a\""), "{message}"); + } + + #[test] + fn a_repeated_lookup_attribute_is_refused() { + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#, + ); + assert!(message.contains("repeated `name`"), "{message}"); + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#, + ); + assert!(message.contains("repeated `index`"), "{message}"); + let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#); + assert!(message.contains("repeated `global`"), "{message}"); + } + + // Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before. + #[test] + fn lookup_attributes_are_refused_on_a_slots_record() { + let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#); + assert!(message.contains("unsupported field cultcache attribute"), "{message}"); + let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#); + assert!(message.contains("unsupported field cultcache attribute"), "{message}"); + } + } diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index b77f4971..4ceb43f2 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -162,8 +162,68 @@ pub use cultcache_rs_derive::DatabaseEntry; pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { const TYPE: &'static str; const SCHEMA_NAME: &'static str = "DatabaseEntry"; + /// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`. + const GLOBAL: bool = false; + /// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name. + fn cult_name(&self) -> Option { + None + } + /// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by. + fn cult_indexes(&self) -> Vec<(&'static str, String)> { + Vec::new() + } } +/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member +/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none. +pub trait CultLookupValue { + fn lookup_value(&self) -> Option; +} + +impl CultLookupValue for String { + fn lookup_value(&self) -> Option { + Some(self.clone()) + } +} + +impl CultLookupValue for Option { + fn lookup_value(&self) -> Option { + self.as_ref().and_then(CultLookupValue::lookup_value) + } +} + +macro_rules! lookup_value_by_display { + ($($ty:ty),* $(,)?) => {$( + impl CultLookupValue for $ty { + fn lookup_value(&self) -> Option { + Some(self.to_string()) + } + } + )*}; +} + +lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize); + +/// A global type already holds a record under another key; a second would leave `get_global` with two answers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CultCacheSecondGlobal { + pub schema_name: String, + pub existing_key: String, + pub key: String, +} + +impl std::fmt::Display for CultCacheSecondGlobal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "{} is a global and already has record {}; {} would be a second.", + self.schema_name, self.existing_key, self.key + ) + } +} + +impl std::error::Error for CultCacheSecondGlobal {} + /// 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( @@ -2655,6 +2715,8 @@ struct CultCacheStoreRegistration { pub struct CultCache { definitions: BTreeMap, + /// The registered types declared `#[cultcache(global)]`. + globals: BTreeSet, schema_name_definitions: BTreeMap, entries: BTreeMap<(String, String), CultCacheEnvelope>, stores: Vec, @@ -2664,6 +2726,7 @@ impl CultCache { pub fn new() -> Self { Self { definitions: BTreeMap::new(), + globals: BTreeSet::new(), schema_name_definitions: BTreeMap::new(), entries: BTreeMap::new(), stores: Vec::new(), @@ -2694,6 +2757,11 @@ impl CultCache { )); } self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); + if T::GLOBAL { + self.globals.insert(T::TYPE.to_string()); + } else { + self.globals.remove(T::TYPE); + } self.schema_name_definitions .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) @@ -2800,6 +2868,7 @@ impl CultCache { loaded.insert(entry_id(&entry), entry); } } + self.refuse_second_globals(&loaded)?; self.entries = loaded; Ok(()) } @@ -2883,6 +2952,65 @@ impl CultCache { Ok(values) } + /// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is + /// not a global). + pub fn get_global(&self) -> Result> { + self.require_entry_type::()?; + if !T::GLOBAL { + return Ok(None); + } + self.find_record::(|_| true) + } + + /// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records + /// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or + /// loaded in; removing it finds the next. + pub fn get_by_name(&self, name: &str) -> Result> { + self.require_entry_type::()?; + if name.is_empty() { + return Ok(None); + } + self.find_record::(|document| document.cult_name().as_deref() == Some(name)) + } + + /// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds + /// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they + /// were written or loaded in; removing it finds the next. + pub fn get_by_index(&self, alias: &str, value: &str) -> Result> { + self.require_entry_type::()?; + if value.trim().is_empty() { + return Ok(None); + } + self.find_record::(|document| { + document + .cult_indexes() + .iter() + .any(|(held_alias, held_value)| *held_alias == alias && held_value == value) + }) + } + + /// The first record of `T`, in key order, that `matches`. + fn find_record(&self, matches: impl Fn(&T) -> bool) -> Result> { + let held = self + .entries + .range((T::TYPE.to_string(), String::new())..) + .take_while(|((held_type, _), _)| held_type == T::TYPE); + for (_, entry) in held { + let document: T = decode_payload(&entry.payload).with_context(|| { + format!( + "failed to decode CultCache entry {:?} at key {:?} as {}", + T::TYPE, + entry.key, + T::SCHEMA_NAME + ) + })?; + if matches(&document) { + return Ok(Some(document)); + } + } + Ok(None) + } + pub fn soa(&self) -> Result> { let keyed_rows = self.get_all_with_keys::()?; let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip(); @@ -2900,6 +3028,7 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { let (entry, parsed) = self.prepare_entry(key, value)?; + self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } @@ -3011,6 +3140,7 @@ impl CultCache { for entry in &staged { next.insert(entry_id(entry), entry.clone()); } + self.refuse_second_globals(&next)?; if let Some(store_index) = store_index { // The home store receives its own records only: those it already // holds in this cache plus the staged ones. @@ -3029,6 +3159,7 @@ impl CultCache { pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&entry)?; + self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } @@ -3061,6 +3192,7 @@ impl CultCache { )); } require_payload_depth(&entry)?; + self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(&entry.r#type)? { self.stores[home].store.push(&entry)?; } @@ -3146,10 +3278,43 @@ impl CultCache { self.home_index(&entry.r#type)?; loaded.insert(entry_id(&entry), entry); } + self.refuse_second_globals(&loaded)?; self.entries = loaded; Ok(()) } + fn refuse_second_global( + &self, + entries: &BTreeMap<(String, String), CultCacheEnvelope>, + type_id: &str, + key: &str, + ) -> Result<()> { + if !self.globals.contains(type_id) { + return Ok(()); + } + let held_other = entries + .range((type_id.to_string(), String::new())..) + .take_while(|((held_type, _), _)| held_type == type_id) + .map(|((_, held_key), _)| held_key) + .find(|held_key| held_key.as_str() != key); + match held_other { + Some(existing_key) => Err(CultCacheSecondGlobal { + schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(), + existing_key: existing_key.clone(), + key: key.to_string(), + } + .into()), + None => Ok(()), + } + } + + fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> { + for (type_id, key) in entries.keys() { + self.refuse_second_global(entries, type_id, key)?; + } + Ok(()) + } + fn require_entry_type(&self) -> Result<()> { match self.definitions.get(T::TYPE) { Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), -- 2.47.3 From 2528fbc090870356597051fa9f5d38dd097c4eef Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 19:44:36 +0000 Subject: [PATCH 2/3] cultcache-rs: lookups parity tests ported from the C# suite Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-rs/tests/lookups.rs | 306 +++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 packages/cultcache-rs/tests/lookups.rs diff --git a/packages/cultcache-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs new file mode 100644 index 00000000..2c3c4f7c --- /dev/null +++ b/packages/cultcache-rs/tests/lookups.rs @@ -0,0 +1,306 @@ +//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal` +//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces. + +use cultcache_rs::{ + CacheBackingStore, CultCache, CultCacheSecondGlobal, DatabaseEntry, SingleFileMessagePackBackingStore, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "item")] +struct Item { + #[cultcache(key = 0, name)] + name: String, + #[cultcache(key = 1, index)] + category: String, + #[cultcache(key = 2, index = "sku")] + code: Option, + #[cultcache(key = 3, default)] + value: u32, +} + +fn item(name: &str, category: &str, code: Option<&str>) -> Item { + Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 } +} + +/// Same names and values as `Item`, a different type: lookups never cross types. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gadget")] +struct Gadget { + #[cultcache(key = 0, name)] + name: String, + #[cultcache(key = 1, index)] + category: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "settings", global)] +struct Settings { + #[cultcache(key = 0)] + value: String, +} + +fn settings(value: &str) -> Settings { + Settings { value: value.into() } +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "plain")] +struct Plain { + #[cultcache(key = 0)] + value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "numbered")] +struct Numbered { + #[cultcache(key = 0, name)] + id: u32, + #[cultcache(key = 1, index)] + owner: u64, +} + +fn cache() -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache +} + +fn reopened(path: &std::path::Path) -> CultCache { + let mut reopened = cache(); + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + reopened.pull_all_backing_stores().unwrap(); + reopened +} + +fn stored_cache(path: &std::path::Path) -> CultCache { + let mut stored = cache(); + stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + stored.pull_all_backing_stores().unwrap(); + stored +} + +// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache. +#[test] +fn a_record_is_found_by_name_and_by_index_after_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lookups.cc"); + let mut written = stored_cache(&path); + written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap(); + + let reopened = reopened(&path); + let by_name = reopened.get_by_name::("Potion").unwrap().unwrap(); + assert_eq!(by_name.value, 50); + assert_eq!(reopened.get_by_index::("category", "Consumable").unwrap(), Some(by_name.clone())); + assert_eq!(reopened.get_by_index::("sku", "P-1").unwrap(), Some(by_name)); +} + +#[test] +fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() { + let mut cache = cache(); + cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap(); + + assert_eq!(cache.get_by_name::("B").unwrap(), None); + assert_eq!(cache.get_by_name::("a").unwrap(), None, "names compare ordinally"); + assert_eq!(cache.get_by_index::("category", "other").unwrap(), None); + assert_eq!(cache.get_by_index::("nosuchalias", "cat").unwrap(), None); + assert_eq!(cache.get_by_index::("code", "a-1").unwrap(), None, "the alias is sku, not the member name"); + + let bare = CultCache::new(); + assert!(bare.get_by_name::("A").is_err()); + assert!(bare.get_by_index::("category", "cat").is_err()); + assert!(bare.get_global::().is_err()); +} + +#[test] +fn an_index_without_an_alias_is_named_by_its_member() { + let mut cache = cache(); + cache.put("item:a", &item("A", "cat", None)).unwrap(); + assert!(cache.get_by_index::("category", "cat").unwrap().is_some()); +} + +// A blank name is no name; a blank index value is not indexed; a nil member holds no value. +#[test] +fn blank_and_nil_values_are_not_found() { + let mut cache = cache(); + cache.put("item:blank", &item("", " ", None)).unwrap(); + assert_eq!(cache.get_by_name::("").unwrap(), None); + assert_eq!(cache.get_by_index::("category", " ").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "").unwrap(), None); + + // Only emptiness voids a name: whitespace is a name, as in C# (Length > 0). + cache.put("item:space", &item(" ", "x", None)).unwrap(); + assert_eq!(cache.get_by_name::(" ").unwrap().unwrap().category, "x"); +} + +#[test] +fn lookups_never_cross_types() { + let mut cache = cache(); + cache.put("item:a", &item("same", "same", None)).unwrap(); + cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap(); + + assert_eq!(cache.get_by_name::("same").unwrap().unwrap().name, "same"); + cache.delete::("item:a").unwrap(); + assert_eq!(cache.get_by_name::("same").unwrap(), None); + assert!(cache.get_by_name::("same").unwrap().is_some()); + assert!(cache.get_by_index::("category", "same").unwrap().is_some()); +} + +#[test] +fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() { + let mut cache = cache(); + cache.put("item:a", &item("old", "c1", None)).unwrap(); + cache.put("item:a", &item("new", "c2", None)).unwrap(); + + assert_eq!(cache.get_by_name::("old").unwrap(), None, "a stale name is not found"); + assert_eq!(cache.get_by_index::("category", "c1").unwrap(), None, "a stale value is not found"); + assert!(cache.get_by_name::("new").unwrap().is_some()); + assert!(cache.get_by_index::("category", "c2").unwrap().is_some()); + + cache.delete::("item:a").unwrap(); + assert_eq!(cache.get_by_name::("new").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "c2").unwrap(), None); +} + +#[test] +fn non_string_members_are_found_by_their_text() { + let mut cache = cache(); + let owner = 161_u64; + cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap(); + assert!(cache.get_by_name::("42").unwrap().is_some()); + assert!(cache.get_by_index::("owner", "161").unwrap().is_some()); +} + +// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with +// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other. +#[test] +fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shared.cc"); + { + let mut cache = stored_cache(&path); + cache.put("item:p2", &item("p2", "x", None)).unwrap(); + cache.put("item:p1", &item("p1", "x", None)).unwrap(); + let found = |cache: &CultCache| cache.get_by_index::("category", "x").unwrap().map(|found| found.name); + assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second"); + assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap().unwrap().name, "p1"); + + cache.delete::("item:p1").unwrap(); + assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one"); + + cache.put("item:p1", &item("p1", "x", None)).unwrap(); + assert_eq!(found(&cache).as_deref(), Some("p1")); + cache.delete::("item:p2").unwrap(); + assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing"); + cache.delete::("item:p1").unwrap(); + assert_eq!(found(&cache), None); + } + assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap(), None); +} + +#[test] +fn records_sharing_a_name_find_the_smallest_key() { + let mut cache = cache(); + cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap(); + cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap(); + assert_eq!(cache.get_by_name::("twin").unwrap().unwrap().value, 1); + cache.delete::("item:a").unwrap(); + assert_eq!(cache.get_by_name::("twin").unwrap().unwrap().value, 2); +} + +// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen. +#[test] +fn a_global_is_none_until_written_and_survives_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("global.cc"); + { + let mut seed = stored_cache(&path); + assert_eq!(seed.get_global::().unwrap(), None, "opening a store invented a global"); + seed.put("global:settings", &settings("durable authority")).unwrap(); + assert_eq!(seed.get_global::().unwrap(), Some(settings("durable authority"))); + } + assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("durable authority"))); +} + +#[test] +fn a_type_that_is_not_a_global_has_no_global() { + let mut cache = cache(); + cache.put("plain:one", &Plain { value: "v".into() }).unwrap(); + assert_eq!(cache.get_global::().unwrap(), None); +} + +#[test] +fn a_second_global_under_another_key_is_refused_and_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("second.cc"); + let mut cache = stored_cache(&path); + cache.put("g1", &settings("one")).unwrap(); + + let refusal = cache.put("g2", &settings("two")).unwrap_err(); + let second = refusal.downcast_ref::().expect("a typed refusal"); + assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2")); + assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second.")); + assert_eq!(cache.get_global::().unwrap(), Some(settings("one"))); + assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("one")), "nothing reached the store"); + + // The same key is the same record, not a second. + cache.put("g1", &settings("one again")).unwrap(); + assert_eq!(cache.get_global::().unwrap(), Some(settings("one again"))); +} + +// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key. +#[test] +fn removing_a_global_frees_its_slot() { + let mut cache = cache(); + cache.put("g1", &settings("one")).unwrap(); + assert!(cache.put("g2", &settings("two")).is_err()); + cache.delete::("g1").unwrap(); + assert_eq!(cache.get_global::().unwrap(), None); + cache.put("g2", &settings("two")).unwrap(); + assert_eq!(cache.get_global::().unwrap(), Some(settings("two"))); +} + +#[test] +fn every_write_path_refuses_a_second_global() { + let mut cache = cache(); + cache.put("g1", &settings("one")).unwrap(); + let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap(); + + assert!(cache.put_envelope::(second.clone()).unwrap_err().is::()); + assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::()); + let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap(); + assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::()); + assert_eq!(cache.get::("plain:1").unwrap(), None, "a refused batch lands none of it"); + assert_eq!(cache.get_global::().unwrap(), Some(settings("one"))); +} + +#[test] +fn a_batch_that_stages_two_globals_is_refused_whole() { + let mut cache = cache(); + let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap(); + let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap(); + assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::()); + assert_eq!(cache.get_global::().unwrap(), None); +} + +// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them. +#[test] +fn loading_a_store_that_holds_two_globals_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("two.cc"); + let source = cache(); + let mut store = SingleFileMessagePackBackingStore::new(&path); + for (key, value) in [("g1", "one"), ("g2", "two")] { + let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap(); + store.push(&entry).unwrap(); + } + + let mut loading = cache(); + loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + assert!(loading.pull_all_backing_stores().unwrap_err().is::()); + assert_eq!(loading.get_global::().unwrap(), None, "a refused load installs nothing"); +} -- 2.47.3 From 95d8be34a76d091b8a5add7f9294a21100dd6bf0 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 19:45:56 +0000 Subject: [PATCH 3/3] cultcache-rs: lookups cross-type test, changelog and README entries Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/README.md | 3 +++ packages/cultcache-rs/tests/lookups.rs | 17 +++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index f15fcee2..c417d535 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -13,6 +13,7 @@ only in git history. ### Added +- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`). - `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::()`. ## [0.3.1] diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index afae7285..2c5c8027 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -174,6 +174,9 @@ without pretending Rust has runtime reflection hiding under the floorboards. - `get_required_envelope::` - `get_all::` - `get_all_with_keys::` +- `get_by_name::` +- `get_by_index::` +- `get_global::` - `soa::` - `put::` - `put_envelope::` diff --git a/packages/cultcache-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs index 2c3c4f7c..2467fbc6 100644 --- a/packages/cultcache-rs/tests/lookups.rs +++ b/packages/cultcache-rs/tests/lookups.rs @@ -32,6 +32,20 @@ struct Gadget { category: String, } +/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "zed")] +struct Zed { + #[cultcache(key = 0, name)] + name: String, + #[cultcache(key = 1, index)] + category: String, + #[cultcache(key = 2, index = "sku")] + code: Option, + #[cultcache(key = 3, default)] + value: u32, +} + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "settings", global)] struct Settings { @@ -63,6 +77,7 @@ fn cache() -> CultCache { let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); cache.register_entry_type::().unwrap(); cache.register_entry_type::().unwrap(); cache.register_entry_type::().unwrap(); @@ -142,10 +157,12 @@ fn lookups_never_cross_types() { let mut cache = cache(); cache.put("item:a", &item("same", "same", None)).unwrap(); cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap(); + cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap(); assert_eq!(cache.get_by_name::("same").unwrap().unwrap().name, "same"); cache.delete::("item:a").unwrap(); assert_eq!(cache.get_by_name::("same").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "same").unwrap(), None); assert!(cache.get_by_name::("same").unwrap().is_some()); assert!(cache.get_by_index::("category", "same").unwrap().is_some()); } -- 2.47.3