Skip to main content

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}