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