Skip to main content

icydb_core/db/identity/
mod.rs

1//! Module: identity
2//! Responsibility: validated entity/index naming and stable byte ordering contracts.
3//! Does not own: schema metadata, relation policy, or storage-layer persistence.
4//! Boundary: all identity construction/decoding for db data/index key domains.
5//!
6//! Invariants:
7//! - Identities are ASCII, non-empty, and bounded by MAX_* limits.
8//! - All construction paths validate invariants.
9//! - Stored byte representation is canonical and order-preserving.
10//! - Ordering semantics follow the length-prefixed stored-byte layout, not
11//!   lexicographic string ordering.
12
13#![expect(clippy::cast_possible_truncation)]
14
15#[cfg(test)]
16mod tests;
17
18use crate::MAX_INDEX_FIELDS;
19use icydb_utils::to_snake_case;
20use std::{
21    cmp::Ordering,
22    fmt::{self, Display},
23};
24
25///
26/// Constants
27///
28
29pub(super) const MAX_ENTITY_NAME_LEN: usize = 64;
30pub(super) const MAX_INDEX_FIELD_NAME_LEN: usize = 64;
31const MAX_INDEX_NAME_PREFIX_LEN: usize = 5;
32const MAX_ENTITY_NAME_SLUG_LEN: usize = (MAX_ENTITY_NAME_LEN * 3) / 2;
33const MAX_INDEX_FIELD_NAME_SLUG_LEN: usize = (MAX_INDEX_FIELD_NAME_LEN * 3) / 2;
34pub(super) const MAX_INDEX_NAME_LEN: usize = MAX_INDEX_NAME_PREFIX_LEN
35    + MAX_ENTITY_NAME_SLUG_LEN
36    + 2
37    + (MAX_INDEX_FIELDS * MAX_INDEX_FIELD_NAME_SLUG_LEN)
38    + (MAX_INDEX_FIELDS - 1);
39const INDEX_NAME_SEGMENT_DELIMITER: u8 = b'|';
40///
41/// IdentityDecodeError
42/// Decode errors (storage / corruption boundary)
43///
44
45#[derive(Debug)]
46pub enum IdentityDecodeError {
47    InvalidSize,
48
49    InvalidLength,
50
51    NonAscii,
52
53    NonZeroPadding,
54
55    Delimiter,
56}
57
58///
59/// EntityNameError
60///
61
62#[derive(Debug)]
63pub enum EntityNameError {
64    Empty,
65
66    TooLong { len: usize, max: usize },
67
68    NonAscii,
69
70    Delimiter,
71}
72
73///
74/// IndexNameError
75///
76
77#[derive(Debug)]
78pub enum IndexNameError {
79    TooManyFields { len: usize, max: usize },
80
81    NoFields,
82
83    FieldEmpty,
84
85    FieldTooLong { field: String, max: usize },
86
87    FieldNonAscii { field: String },
88
89    FieldDelimiter { field: String },
90
91    TooLong { len: usize, max: usize },
92}
93
94///
95/// EntityName
96///
97
98#[derive(Clone, Copy, Eq, Hash, PartialEq)]
99pub struct EntityName {
100    len: u8,
101    bytes: [u8; MAX_ENTITY_NAME_LEN],
102}
103
104impl EntityName {
105    /// Fixed on-disk size in bytes (stable, protocol-level)
106    pub const STORED_SIZE_BYTES: u64 = 1 + (MAX_ENTITY_NAME_LEN as u64);
107
108    /// Fixed in-memory size (for buffers and arrays)
109    pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
110
111    /// Validate and construct an entity name from one ASCII string.
112    pub fn try_from_str(name: &str) -> Result<Self, EntityNameError> {
113        // Phase 1: validate user-visible identity constraints.
114        let bytes = name.as_bytes();
115        let len = bytes.len();
116
117        if len == 0 {
118            return Err(EntityNameError::Empty);
119        }
120        if len > MAX_ENTITY_NAME_LEN {
121            return Err(EntityNameError::TooLong {
122                len,
123                max: MAX_ENTITY_NAME_LEN,
124            });
125        }
126        if !bytes.is_ascii() {
127            return Err(EntityNameError::NonAscii);
128        }
129        if bytes.contains(&INDEX_NAME_SEGMENT_DELIMITER) {
130            return Err(EntityNameError::Delimiter);
131        }
132
133        // Phase 2: write into fixed-size canonical storage.
134        let mut out = [0u8; MAX_ENTITY_NAME_LEN];
135        out[..len].copy_from_slice(bytes);
136
137        Ok(Self {
138            len: len as u8,
139            bytes: out,
140        })
141    }
142
143    /// Return the stored entity-name length.
144    #[must_use]
145    pub const fn len(&self) -> usize {
146        self.len as usize
147    }
148
149    /// Return whether the stored entity-name length is zero.
150    #[must_use]
151    pub const fn is_empty(&self) -> bool {
152        self.len() == 0
153    }
154
155    /// Borrow raw identity bytes excluding trailing fixed-buffer padding.
156    #[must_use]
157    pub fn as_bytes(&self) -> &[u8] {
158        &self.bytes[..self.len()]
159    }
160
161    /// Borrow the entity name as UTF-8 text.
162    ///
163    /// # Panics
164    ///
165    /// Panics if the stored entity-name bytes violate the ASCII-only identity
166    /// invariant. Construction and decoding are expected to prevent this.
167    #[must_use]
168    pub fn as_str(&self) -> &str {
169        // Invariant: construction and decoding enforce ASCII-only storage,
170        // so UTF-8 decoding cannot fail.
171        std::str::from_utf8(self.as_bytes()).expect("EntityName invariant: ASCII-only storage")
172    }
173
174    /// Encode this identity into its fixed-size persisted representation.
175    #[must_use]
176    pub fn to_bytes(self) -> [u8; Self::STORED_SIZE_USIZE] {
177        let mut out = [0u8; Self::STORED_SIZE_USIZE];
178        out[0] = self.len;
179        out[1..].copy_from_slice(&self.bytes);
180        out
181    }
182
183    /// Decode one fixed-size persisted entity identity payload.
184    pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityDecodeError> {
185        // Phase 1: validate layout and payload bounds.
186        if bytes.len() != Self::STORED_SIZE_USIZE {
187            return Err(IdentityDecodeError::InvalidSize);
188        }
189
190        let len = bytes[0] as usize;
191        if len == 0 || len > MAX_ENTITY_NAME_LEN {
192            return Err(IdentityDecodeError::InvalidLength);
193        }
194        if !bytes[1..=len].is_ascii() {
195            return Err(IdentityDecodeError::NonAscii);
196        }
197        if bytes[1..=len].contains(&INDEX_NAME_SEGMENT_DELIMITER) {
198            return Err(IdentityDecodeError::Delimiter);
199        }
200        if bytes[1 + len..].iter().any(|&b| b != 0) {
201            return Err(IdentityDecodeError::NonZeroPadding);
202        }
203
204        // Phase 2: materialize canonical fixed-buffer identity storage.
205        let mut name = [0u8; MAX_ENTITY_NAME_LEN];
206        name.copy_from_slice(&bytes[1..]);
207
208        Ok(Self {
209            len: len as u8,
210            bytes: name,
211        })
212    }
213}
214
215impl Ord for EntityName {
216    fn cmp(&self, other: &Self) -> Ordering {
217        // Keep ordering consistent with `to_bytes()` (length prefix first).
218        // This is deterministic protocol/storage ordering, not lexical string order.
219        self.len.cmp(&other.len).then(self.bytes.cmp(&other.bytes))
220    }
221}
222
223impl PartialOrd for EntityName {
224    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
225        Some(self.cmp(other))
226    }
227}
228
229impl Display for EntityName {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.write_str(self.as_str())
232    }
233}
234
235impl fmt::Debug for EntityName {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        write!(f, "EntityName({})", self.as_str())
238    }
239}
240
241///
242/// IndexName
243///
244
245#[derive(Clone, Copy, Eq, Hash, PartialEq)]
246pub struct IndexName {
247    len: u16,
248    bytes: [u8; MAX_INDEX_NAME_LEN],
249}
250
251impl IndexName {
252    /// Fixed on-disk size in bytes (stable, protocol-level).
253    pub const STORED_SIZE_BYTES: u64 = 2 + (MAX_INDEX_NAME_LEN as u64);
254    /// Fixed in-memory size (for buffers and arrays).
255    pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
256
257    /// Validate and construct one non-unique index identity from an entity +
258    /// field list.
259    pub fn try_from_entity_fields(
260        entity: &EntityName,
261        fields: &[&str],
262    ) -> Result<Self, IndexNameError> {
263        Self::try_from_entity_fields_with_prefix("idx", entity, fields)
264    }
265
266    /// Validate and construct one unique index identity from an entity + field
267    /// list.
268    pub fn try_unique_from_entity_fields(
269        entity: &EntityName,
270        fields: &[&str],
271    ) -> Result<Self, IndexNameError> {
272        Self::try_from_entity_fields_with_prefix("uniq", entity, fields)
273    }
274
275    fn try_from_entity_fields_with_prefix(
276        prefix: &str,
277        entity: &EntityName,
278        fields: &[&str],
279    ) -> Result<Self, IndexNameError> {
280        // Phase 1: validate index-field count and per-field identity constraints.
281        if fields.is_empty() {
282            return Err(IndexNameError::NoFields);
283        }
284        if fields.len() > MAX_INDEX_FIELDS {
285            return Err(IndexNameError::TooManyFields {
286                len: fields.len(),
287                max: MAX_INDEX_FIELDS,
288            });
289        }
290
291        let mut field_slugs = Vec::with_capacity(fields.len());
292        for field in fields {
293            let field_len = field.len();
294            if field_len == 0 {
295                return Err(IndexNameError::FieldEmpty);
296            }
297            if field_len > MAX_INDEX_FIELD_NAME_LEN {
298                return Err(IndexNameError::FieldTooLong {
299                    field: (*field).to_string(),
300                    max: MAX_INDEX_FIELD_NAME_LEN,
301                });
302            }
303            if !field.is_ascii() {
304                return Err(IndexNameError::FieldNonAscii {
305                    field: (*field).to_string(),
306                });
307            }
308            if field.as_bytes().contains(&INDEX_NAME_SEGMENT_DELIMITER) {
309                return Err(IndexNameError::FieldDelimiter {
310                    field: (*field).to_string(),
311                });
312            }
313            let slug = index_name_slug(field);
314            if slug.is_empty() {
315                return Err(IndexNameError::FieldEmpty);
316            }
317            field_slugs.push(slug);
318        }
319
320        let entity_slug = index_name_slug(entity.as_str());
321        let total_len = prefix
322            .len()
323            .saturating_add(1)
324            .saturating_add(entity_slug.len())
325            .saturating_add(2)
326            .saturating_add(field_slugs.iter().map(String::len).sum::<usize>())
327            .saturating_add(field_slugs.len().saturating_sub(1));
328        if total_len > MAX_INDEX_NAME_LEN {
329            return Err(IndexNameError::TooLong {
330                len: total_len,
331                max: MAX_INDEX_NAME_LEN,
332            });
333        }
334
335        // Phase 2: encode canonical `idx_entity__field...` bytes into fixed storage.
336        let mut out = [0u8; MAX_INDEX_NAME_LEN];
337        let mut len = 0usize;
338
339        Self::push_bytes(&mut out, &mut len, prefix.as_bytes());
340        Self::push_bytes(&mut out, &mut len, b"_");
341        Self::push_bytes(&mut out, &mut len, entity_slug.as_bytes());
342        Self::push_bytes(&mut out, &mut len, b"__");
343        for (index, field_slug) in field_slugs.iter().enumerate() {
344            if index > 0 {
345                Self::push_bytes(&mut out, &mut len, b"_");
346            }
347            Self::push_bytes(&mut out, &mut len, field_slug.as_bytes());
348        }
349
350        Ok(Self {
351            len: len as u16,
352            bytes: out,
353        })
354    }
355
356    /// Borrow raw index-identity bytes excluding trailing fixed-buffer padding.
357    #[must_use]
358    pub fn as_bytes(&self) -> &[u8] {
359        &self.bytes[..self.len as usize]
360    }
361
362    /// Borrow the index identity as UTF-8 text.
363    ///
364    /// # Panics
365    ///
366    /// Panics if the stored index-name bytes violate the ASCII-only identity
367    /// invariant. Construction and decoding are expected to prevent this.
368    #[must_use]
369    pub fn as_str(&self) -> &str {
370        // Invariant: construction and decoding enforce ASCII-only storage,
371        // so UTF-8 decoding cannot fail.
372        std::str::from_utf8(self.as_bytes()).expect("IndexName invariant: ASCII-only storage")
373    }
374
375    /// Encode this identity into its fixed-size persisted representation.
376    #[must_use]
377    pub fn to_bytes(self) -> [u8; Self::STORED_SIZE_USIZE] {
378        let mut out = [0u8; Self::STORED_SIZE_USIZE];
379        out[..2].copy_from_slice(&self.len.to_be_bytes());
380        out[2..].copy_from_slice(&self.bytes);
381        out
382    }
383
384    /// Decode one fixed-size persisted index identity payload.
385    ///
386    /// This validates the canonical fixed-width byte envelope only. It does not
387    /// reconstruct field segments or prove the bytes were produced by
388    /// `try_from_entity_fields`; callers must ensure persisted bytes originate from a
389    /// previously validated `IndexName`.
390    pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityDecodeError> {
391        // Phase 1: validate layout and payload bounds.
392        if bytes.len() != Self::STORED_SIZE_USIZE {
393            return Err(IdentityDecodeError::InvalidSize);
394        }
395
396        let len = u16::from_be_bytes([bytes[0], bytes[1]]) as usize;
397        if len == 0 || len > MAX_INDEX_NAME_LEN {
398            return Err(IdentityDecodeError::InvalidLength);
399        }
400        if !bytes[2..2 + len].is_ascii() {
401            return Err(IdentityDecodeError::NonAscii);
402        }
403        if bytes[2 + len..].iter().any(|&b| b != 0) {
404            return Err(IdentityDecodeError::NonZeroPadding);
405        }
406
407        // Phase 2: materialize canonical fixed-buffer identity storage.
408        let mut name = [0u8; MAX_INDEX_NAME_LEN];
409        name.copy_from_slice(&bytes[2..]);
410
411        Ok(Self {
412            len: len as u16,
413            bytes: name,
414        })
415    }
416
417    // Append bytes into the fixed-size identity buffer while tracking write offset.
418    fn push_bytes(out: &mut [u8; MAX_INDEX_NAME_LEN], len: &mut usize, bytes: &[u8]) {
419        let end = *len + bytes.len();
420        out[*len..end].copy_from_slice(bytes);
421        *len = end;
422    }
423}
424
425fn index_name_slug(value: &str) -> String {
426    let separated = value
427        .chars()
428        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
429        .collect::<String>();
430
431    to_snake_case(separated.as_str())
432}
433
434impl Ord for IndexName {
435    fn cmp(&self, other: &Self) -> Ordering {
436        self.to_bytes().cmp(&other.to_bytes())
437    }
438}
439
440impl PartialOrd for IndexName {
441    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
442        Some(self.cmp(other))
443    }
444}
445
446impl fmt::Debug for IndexName {
447    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448        write!(f, "IndexName({})", self.as_str())
449    }
450}
451
452impl Display for IndexName {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        f.write_str(self.as_str())
455    }
456}