aimdb_core/record_id.rs
1//! Record identification types for stable, O(1) lookups
2//!
3//! This module provides key types for record identification:
4//!
5//! - [`RecordKey`]: A **trait** for type-safe record identifiers
6//! - [`StringKey`]: Default string-based key implementation
7//! - [`RecordId`]: An internal index for O(1) hot-path lookups
8//!
9//! # Design Rationale
10//!
11//! AimDB separates *logical identity* (RecordKey) from *physical identity* (RecordId):
12//!
13//! - **RecordKey** is a trait that can be implemented by user-defined enums for
14//! compile-time checked keys, or use the default `StringKey` for string-based keys.
15//!
16//! - **RecordId** is the hot-path identifier used internally for O(1) Vec indexing
17//! during produce/consume operations.
18//!
19//! # Examples
20//!
21//! ## StringKey (default, edge/cloud)
22//!
23//! ```rust
24//! use aimdb_core::record_id::StringKey;
25//!
26//! // Static keys (zero allocation, preferred)
27//! let key: StringKey = "sensors.temperature".into();
28//! assert!(key.is_static());
29//!
30//! // Dynamic keys (for runtime-generated names)
31//! let tenant_id = "acme";
32//! let key = StringKey::intern(format!("tenant.{}.sensors", tenant_id));
33//! assert!(!key.is_static());
34//! ```
35//!
36//! ## Enum Keys (compile-time safe, embedded)
37//!
38//! Illustrative (not compiled: the derive macro lives in `aimdb-derive`,
39//! behind the optional `derive` feature):
40//!
41//! ```rust,ignore
42//! use aimdb_derive::RecordKey;
43//!
44//! #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
45//! pub enum AppKey {
46//! #[key = "temp.indoor"]
47//! TempIndoor,
48//! #[key = "temp.outdoor"]
49//! TempOutdoor,
50//! }
51//!
52//! // Compile-time typo detection!
53//! builder.configure::<Temperature>(AppKey::TempIndoor, |reg| { /* … */ });
54//! ```
55
56use alloc::{boxed::Box, collections::BTreeSet, string::ToString};
57
58#[cfg(feature = "std")]
59type Mutex<T> = std::sync::Mutex<T>;
60#[cfg(not(feature = "std"))]
61type Mutex<T> = spin::Mutex<T>;
62
63/// Locks the intern table, hiding the std/spin API difference
64/// (`std::sync::Mutex::lock` returns a `LockResult`; `spin` returns the guard
65/// directly). A poisoned std mutex is unrecoverable here, so `.unwrap()` is
66/// the correct response. Same pattern as the `TypedRecord` field mutexes.
67#[cfg(feature = "std")]
68fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
69 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
70}
71#[cfg(not(feature = "std"))]
72fn lock<T>(m: &Mutex<T>) -> spin::MutexGuard<'_, T> {
73 m.lock()
74}
75
76/// Dedup table for interned keys: re-interning an already-known key returns
77/// the existing `'static` allocation instead of leaking a new one, bounding
78/// the leak by the number of *distinct* dynamic keys.
79static INTERNED_KEYS: Mutex<BTreeSet<&'static str>> = Mutex::new(BTreeSet::new());
80
81// Re-export derive macro when feature is enabled
82#[cfg(feature = "derive")]
83pub use aimdb_derive::RecordKey;
84
85// ============================================================================
86// RecordKey Trait
87// ============================================================================
88
89/// Trait for record key types
90///
91/// Enables compile-time checked enum keys for embedded while preserving
92/// String flexibility for edge/cloud deployments.
93///
94/// The `Borrow<str>` bound is required for O(1) HashMap lookups by string
95/// in the remote access layer (e.g., `hashmap.get("record.name")`).
96///
97/// # Implementing RecordKey
98///
99/// The easiest way is to use the derive macro (illustrative, not compiled:
100/// the macro lives in `aimdb-derive`, behind the optional `derive` feature):
101///
102/// ```rust,ignore
103/// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
104/// pub enum AppKey {
105/// #[key = "temp.indoor"]
106/// TempIndoor,
107/// #[key = "temp.outdoor"]
108/// TempOutdoor,
109/// }
110/// ```
111///
112/// With connector metadata (MQTT topics, KNX addresses):
113///
114/// ```rust,ignore
115/// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
116/// pub enum SensorKey {
117/// #[key = "temp.indoor"]
118/// #[link_address = "mqtt://sensors/temp/indoor"]
119/// TempIndoor,
120///
121/// #[key = "temp.outdoor"]
122/// #[link_address = "knx://9/1/0"]
123/// TempOutdoor,
124/// }
125/// ```
126///
127/// For manual implementation:
128///
129/// ```rust
130/// use aimdb_core::RecordKey;
131/// use core::borrow::Borrow;
132///
133/// #[derive(Clone, Copy, PartialEq, Eq, Hash)]
134/// pub enum MyKey {
135/// Temperature,
136/// Humidity,
137/// }
138///
139/// impl RecordKey for MyKey {
140/// fn as_str(&self) -> &'static str {
141/// match self {
142/// Self::Temperature => "sensor.temp",
143/// Self::Humidity => "sensor.humid",
144/// }
145/// }
146/// }
147///
148/// impl Borrow<str> for MyKey {
149/// fn borrow(&self) -> &str {
150/// self.as_str()
151/// }
152/// }
153/// ```
154///
155/// # Hash and Borrow Contract
156///
157/// The `RecordKey` trait requires `Hash` because keys are stored in HashMaps.
158/// Per Rust's `Borrow` trait contract, `hash(key) == hash(key.borrow())`—meaning
159/// your `Hash` implementation must hash the same value as `as_str()` returns.
160///
161/// **Using the derive macro:** `#[derive(RecordKey)]` automatically generates
162/// both `Hash` and `Borrow<str>` implementations that satisfy this contract.
163/// Do **not** also derive `Hash` manually—it would conflict.
164///
165/// **Manual implementation:** Implement `Hash` by hashing `self.as_str()`:
166///
167/// ```rust
168/// use core::hash::{Hash, Hasher};
169/// # pub enum MyKey { Temperature }
170/// # impl MyKey {
171/// # fn as_str(&self) -> &'static str { "sensor.temp" }
172/// # }
173/// impl Hash for MyKey {
174/// fn hash<H: Hasher>(&self, state: &mut H) {
175/// self.as_str().hash(state);
176/// }
177/// }
178/// ```
179pub trait RecordKey:
180 Clone + Eq + core::hash::Hash + core::borrow::Borrow<str> + Send + Sync + 'static
181{
182 /// String representation for connectors, logging, serialization, remote access
183 fn as_str(&self) -> &'static str;
184
185 /// Connector address for this key
186 ///
187 /// Returns the URL/address to use with connectors (MQTT topics, KNX addresses, etc.).
188 /// Use with `.link_to()` for outbound or `.link_from()` for inbound connections.
189 #[inline]
190 fn link_address(&self) -> Option<&str> {
191 None
192 }
193}
194
195// Blanket implementation for &'static str
196impl RecordKey for &'static str {
197 #[inline]
198 fn as_str(&self) -> &'static str {
199 self
200 }
201}
202
203// ============================================================================
204// StringKey - Default Implementation
205// ============================================================================
206
207/// Default string-based record key
208///
209/// Supports both static (zero-cost) and interned (leaked) names.
210/// Use string literals for the common case; they auto-convert via `From`.
211///
212/// # Memory Model
213///
214/// Dynamic keys are "interned" by leaking memory into `'static` lifetime.
215/// This is optimal for AimDB's use case where keys are:
216/// - Registered once at startup
217/// - Never deallocated during runtime
218/// - Frequently cloned (now just pointer copy)
219///
220/// The tradeoff: memory for dynamic keys is never freed. This is acceptable
221/// because typical deployments have <100 keys totaling <4KB.
222///
223/// # Naming Convention
224///
225/// Recommended format: `<namespace>.<category>.<instance>`
226///
227/// Examples:
228/// - `sensors.temperature.outdoor`
229/// - `sensors.temperature.indoor`
230/// - `mesh.weather.sf-bay`
231/// - `config.app.settings`
232/// - `tenant.acme.sensors.temp`
233///
234/// # Examples
235///
236/// ```rust
237/// use aimdb_core::record_id::StringKey;
238///
239/// // Static (preferred) - zero allocation
240/// let key: StringKey = "sensors.temperature".into();
241///
242/// // Dynamic - for runtime-generated names
243/// let key = StringKey::intern(format!("tenant.{}.sensors", "acme"));
244/// ```
245#[derive(Clone, Copy)]
246pub struct StringKey(StringKeyInner);
247
248#[derive(Clone, Copy)]
249enum StringKeyInner {
250 /// Static string literal (zero allocation)
251 Static(&'static str),
252 /// Interned runtime string (leaked into 'static lifetime)
253 ///
254 /// Memory is intentionally leaked for O(1) cloning and comparison; a
255 /// dedup table bounds the leak by the number of distinct dynamic keys.
256 /// See [`StringKey::intern`] for the contract.
257 Interned(&'static str),
258}
259
260impl StringKey {
261 /// Create from a static string literal
262 ///
263 /// This is a const fn, usable in const contexts.
264 #[inline]
265 #[must_use]
266 pub const fn new(s: &'static str) -> Self {
267 Self(StringKeyInner::Static(s))
268 }
269
270 /// Create from a runtime-generated string
271 ///
272 /// Use this for dynamic names (multi-tenant, config-driven, etc.).
273 ///
274 /// # Memory contract
275 ///
276 /// Each **distinct** dynamic key allocates once for the lifetime of the
277 /// process (the string is leaked into `'static`); re-interning a known
278 /// key returns the existing allocation. This is what keeps `StringKey`
279 /// `Copy` with O(1) comparison — and it means every distinct key stays
280 /// resident forever. Do **not** derive keys from unbounded input (e.g.
281 /// per-request or per-message IDs). Typical overhead: <4KB for 100 keys.
282 ///
283 #[must_use]
284 pub fn intern(s: impl AsRef<str>) -> Self {
285 let s = s.as_ref();
286
287 // Hold the lock across lookup + leak + insert so a race cannot leak
288 // two allocations for the same key.
289 let mut interned = lock(&INTERNED_KEYS);
290 if let Some(&existing) = interned.get(s) {
291 return Self(StringKeyInner::Interned(existing));
292 }
293
294 let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
295 interned.insert(leaked);
296 Self(StringKeyInner::Interned(leaked))
297 }
298
299 /// Create from a runtime string (alias for `intern`)
300 ///
301 /// The string is leaked into `'static` lifetime for O(1) cloning.
302 #[inline]
303 #[must_use]
304 pub fn from_dynamic(s: &str) -> Self {
305 Self::intern(s)
306 }
307
308 /// Returns true if this is a static (compile-time) key
309 #[inline]
310 pub fn is_static(&self) -> bool {
311 matches!(self.0, StringKeyInner::Static(_))
312 }
313
314 /// Returns true if this is an interned (runtime) key
315 #[inline]
316 pub fn is_interned(&self) -> bool {
317 matches!(self.0, StringKeyInner::Interned(_))
318 }
319}
320
321// Implement RecordKey trait for StringKey
322impl RecordKey for StringKey {
323 #[inline]
324 fn as_str(&self) -> &'static str {
325 match self.0 {
326 StringKeyInner::Static(s) => s,
327 StringKeyInner::Interned(s) => s,
328 }
329 }
330}
331
332// Custom Debug to show Static vs Interned variant
333impl core::fmt::Debug for StringKey {
334 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
335 match self.0 {
336 StringKeyInner::Static(s) => f.debug_tuple("StringKey::Static").field(&s).finish(),
337 StringKeyInner::Interned(s) => f.debug_tuple("StringKey::Interned").field(&s).finish(),
338 }
339 }
340}
341
342impl core::hash::Hash for StringKey {
343 fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
344 // Hash the string content, not the enum variant
345 self.as_str().hash(state);
346 }
347}
348
349impl PartialEq for StringKey {
350 fn eq(&self, other: &Self) -> bool {
351 self.as_str() == other.as_str()
352 }
353}
354
355impl Eq for StringKey {}
356
357/// Enable direct comparison with &str
358impl PartialEq<str> for StringKey {
359 fn eq(&self, other: &str) -> bool {
360 self.as_str() == other
361 }
362}
363
364/// Enable direct comparison with &str reference
365impl PartialEq<&str> for StringKey {
366 fn eq(&self, other: &&str) -> bool {
367 self.as_str() == *other
368 }
369}
370
371impl PartialOrd for StringKey {
372 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
373 Some(self.cmp(other))
374 }
375}
376
377impl Ord for StringKey {
378 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
379 self.as_str().cmp(other.as_str())
380 }
381}
382
383/// Ergonomic conversion from string literals
384impl From<&'static str> for StringKey {
385 #[inline]
386 fn from(s: &'static str) -> Self {
387 Self::new(s)
388 }
389}
390
391/// Ergonomic conversion from owned String (no_std with alloc)
392#[cfg(all(feature = "alloc", not(feature = "std")))]
393impl From<alloc::string::String> for StringKey {
394 #[inline]
395 fn from(s: alloc::string::String) -> Self {
396 Self::intern(s)
397 }
398}
399
400/// Ergonomic conversion from owned String (std)
401#[cfg(feature = "std")]
402impl From<String> for StringKey {
403 #[inline]
404 fn from(s: String) -> Self {
405 Self::intern(s)
406 }
407}
408
409impl core::fmt::Display for StringKey {
410 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
411 f.write_str(self.as_str())
412 }
413}
414
415impl AsRef<str> for StringKey {
416 fn as_ref(&self) -> &str {
417 self.as_str()
418 }
419}
420
421/// Enable O(1) HashMap lookup by &str
422///
423/// This allows `hashmap.get("string_literal")` without allocating a StringKey.
424impl core::borrow::Borrow<str> for StringKey {
425 fn borrow(&self) -> &str {
426 self.as_str()
427 }
428}
429
430// ===== Serde Support (std only) =====
431
432#[cfg(feature = "std")]
433impl serde::Serialize for StringKey {
434 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
435 serializer.serialize_str(self.as_str())
436 }
437}
438
439#[cfg(feature = "std")]
440impl<'de> serde::Deserialize<'de> for StringKey {
441 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
442 let s = String::deserialize(deserializer)?;
443 Ok(Self::intern(s))
444 }
445}
446
447// ============================================================================
448// RecordId - Internal Index
449// ============================================================================
450
451/// Internal record identifier (index into storage Vec)
452///
453/// This is the hot-path identifier used for O(1) lookups during
454/// produce/consume operations. Not exposed to external APIs.
455///
456/// # Why u32?
457///
458/// - 4 billion records is more than enough for any deployment
459/// - Smaller than `usize` on 64-bit systems (cache efficiency)
460/// - Copy-friendly (no clone overhead)
461#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
462pub struct RecordId(pub(crate) u32);
463
464impl RecordId {
465 /// Create a new RecordId from an index
466 ///
467 /// # Arguments
468 /// * `index` - The index into the record storage array
469 #[inline]
470 pub const fn new(index: u32) -> Self {
471 Self(index)
472 }
473
474 /// Get the underlying index
475 #[inline]
476 pub const fn index(self) -> usize {
477 self.0 as usize
478 }
479
480 /// Get the raw u32 value (for serialization)
481 #[inline]
482 pub const fn raw(self) -> u32 {
483 self.0
484 }
485}
486
487impl core::fmt::Display for RecordId {
488 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
489 write!(f, "RecordId({})", self.0)
490 }
491}
492
493#[cfg(feature = "std")]
494impl serde::Serialize for RecordId {
495 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
496 serializer.serialize_u32(self.0)
497 }
498}
499
500#[cfg(feature = "std")]
501impl<'de> serde::Deserialize<'de> for RecordId {
502 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
503 let index = u32::deserialize(deserializer)?;
504 Ok(Self(index))
505 }
506}
507
508// ===== Unit Tests (std only - uses String, HashMap, format!) =====
509
510#[cfg(all(test, feature = "std"))]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn test_string_key_static() {
516 let key: StringKey = "sensors.temperature".into();
517 assert!(key.is_static());
518 assert_eq!(key.as_str(), "sensors.temperature");
519 }
520
521 #[test]
522 fn test_string_key_interned() {
523 // Concatenation creates a truly dynamic String
524 let key = StringKey::intern(["sensors", ".", "temperature"].concat());
525 assert!(!key.is_static());
526 assert!(key.is_interned());
527 assert_eq!(key.as_str(), "sensors.temperature");
528 }
529
530 #[test]
531 fn test_intern_dedup_returns_same_allocation() {
532 // Two separately built Strings with the same content must intern to
533 // the very same 'static allocation (leak bounded by distinct keys).
534 let a = StringKey::intern(["dedup", ".", "sensor"].concat());
535 let b = StringKey::intern(["dedup", ".", "sensor"].concat());
536 assert!(core::ptr::eq(a.as_str(), b.as_str()));
537
538 // A different key gets its own allocation.
539 let c = StringKey::intern(["dedup", ".", "other"].concat());
540 assert!(!core::ptr::eq(a.as_str(), c.as_str()));
541 }
542
543 #[test]
544 fn test_string_key_equality() {
545 let static_key: StringKey = "sensors.temp".into();
546 // Concatenation creates a truly dynamic String
547 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
548
549 // Static and interned keys with same content should be equal
550 assert_eq!(static_key, interned_key);
551 }
552
553 #[test]
554 fn test_string_key_hash_consistency() {
555 use core::hash::{Hash, Hasher};
556 use std::collections::hash_map::DefaultHasher;
557
558 fn hash_key(key: &StringKey) -> u64 {
559 let mut hasher = DefaultHasher::new();
560 key.hash(&mut hasher);
561 hasher.finish()
562 }
563
564 let static_key: StringKey = "sensors.temp".into();
565 // Concatenation creates a truly dynamic String
566 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
567
568 // Hash should be the same for equal keys
569 assert_eq!(hash_key(&static_key), hash_key(&interned_key));
570 }
571
572 #[test]
573 fn test_string_key_borrow() {
574 use std::collections::HashMap;
575
576 let mut map: HashMap<StringKey, i32> = HashMap::new();
577 map.insert("sensors.temp".into(), 42);
578
579 // Can lookup by &str without allocation
580 assert_eq!(map.get("sensors.temp"), Some(&42));
581 }
582
583 #[test]
584 fn test_record_id_basic() {
585 let id = RecordId::new(42);
586 assert_eq!(id.index(), 42);
587 assert_eq!(id.raw(), 42);
588 }
589
590 #[test]
591 fn test_record_id_copy() {
592 let id1 = RecordId::new(10);
593 let id2 = id1; // Copy, not move
594 assert_eq!(id1, id2);
595 }
596
597 #[test]
598 fn test_string_key_display() {
599 let key: StringKey = "sensors.temperature".into();
600 assert_eq!(format!("{}", key), "sensors.temperature");
601 }
602
603 #[test]
604 fn test_string_key_debug() {
605 let static_key: StringKey = "sensors.temp".into();
606 // Concatenation creates a truly dynamic String
607 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
608
609 // Debug output should distinguish static vs interned
610 let static_debug = format!("{:?}", static_key);
611 let interned_debug = format!("{:?}", interned_key);
612
613 assert!(static_debug.contains("Static"));
614 assert!(interned_debug.contains("Interned"));
615 }
616
617 #[test]
618 fn test_string_key_partial_eq_str() {
619 let key: StringKey = "sensors.temperature".into();
620
621 // Direct comparison with &str
622 assert!(key == "sensors.temperature");
623 assert!(key != "other.key");
624
625 // Also works with str reference
626 let s: &str = "sensors.temperature";
627 assert!(key == s);
628 }
629
630 #[test]
631 fn test_string_key_from_string() {
632 let owned = "sensors.temperature".to_string();
633 let key: StringKey = owned.into();
634
635 assert!(!key.is_static());
636 assert_eq!(key.as_str(), "sensors.temperature");
637 }
638
639 #[test]
640 fn test_record_id_display() {
641 let id = RecordId::new(42);
642 assert_eq!(format!("{}", id), "RecordId(42)");
643 }
644
645 #[test]
646 fn test_static_str_record_key() {
647 // &'static str implements RecordKey
648 let key: &'static str = "sensors.temp";
649 assert_eq!(<&str as RecordKey>::as_str(&key), "sensors.temp");
650 }
651
652 #[test]
653 fn test_string_key_record_key_trait() {
654 use crate::RecordKey;
655
656 let key: StringKey = "sensors.temp".into();
657 assert_eq!(RecordKey::as_str(&key), "sensors.temp");
658 }
659
660 /// Test that hash(key) == hash(key.borrow()) per Rust's Borrow trait contract.
661 ///
662 /// This is critical for HashMap operations: if Borrow<str> is implemented,
663 /// the hash of the key must match the hash of its borrowed string form.
664 #[test]
665 fn test_string_key_hash_borrow_contract() {
666 use core::borrow::Borrow;
667 use core::hash::{Hash, Hasher};
668 use std::collections::hash_map::DefaultHasher;
669
670 fn hash_value<T: Hash>(t: &T) -> u64 {
671 let mut h = DefaultHasher::new();
672 t.hash(&mut h);
673 h.finish()
674 }
675
676 // Test static key
677 let static_key: StringKey = "sensors.temp".into();
678 let borrowed: &str = static_key.borrow();
679 assert_eq!(
680 hash_value(&static_key),
681 hash_value(&borrowed),
682 "Static StringKey hash must match its borrowed string hash"
683 );
684
685 // Test interned key
686 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
687 let borrowed: &str = interned_key.borrow();
688 assert_eq!(
689 hash_value(&interned_key),
690 hash_value(&borrowed),
691 "Interned StringKey hash must match its borrowed string hash"
692 );
693
694 // Test that HashMap lookup by &str works (practical consequence)
695 use std::collections::HashMap;
696 let mut map: HashMap<StringKey, i32> = HashMap::new();
697 map.insert("key.one".into(), 1);
698 map.insert(StringKey::intern("key.two"), 2);
699
700 assert_eq!(map.get("key.one"), Some(&1));
701 assert_eq!(map.get("key.two"), Some(&2));
702 assert_eq!(map.get("key.nonexistent"), None);
703 }
704}