#![expect(clippy::cast_possible_truncation)]
#[cfg(test)]
mod tests;
use crate::MAX_INDEX_FIELDS;
use icydb_utils::to_snake_case;
use std::{
cmp::Ordering,
fmt::{self, Display},
};
use thiserror::Error as ThisError;
pub(super) const MAX_ENTITY_NAME_LEN: usize = 64;
pub(super) const MAX_INDEX_FIELD_NAME_LEN: usize = 64;
const MAX_INDEX_NAME_PREFIX_LEN: usize = 5;
const MAX_ENTITY_NAME_SLUG_LEN: usize = (MAX_ENTITY_NAME_LEN * 3) / 2;
const MAX_INDEX_FIELD_NAME_SLUG_LEN: usize = (MAX_INDEX_FIELD_NAME_LEN * 3) / 2;
pub(super) const MAX_INDEX_NAME_LEN: usize = MAX_INDEX_NAME_PREFIX_LEN
+ MAX_ENTITY_NAME_SLUG_LEN
+ 2
+ (MAX_INDEX_FIELDS * MAX_INDEX_FIELD_NAME_SLUG_LEN)
+ (MAX_INDEX_FIELDS - 1);
const INDEX_NAME_SEGMENT_DELIMITER: u8 = b'|';
const MAX_ASCII_BYTE: u8 = 0x7F;
#[derive(Debug, ThisError)]
pub enum IdentityDecodeError {
#[error("invalid size")]
InvalidSize,
#[error("invalid length")]
InvalidLength,
#[error("non-ascii encoding")]
NonAscii,
#[error("non-zero padding")]
NonZeroPadding,
#[error("reserved identity delimiter")]
Delimiter,
}
#[derive(Debug, ThisError)]
pub enum EntityNameError {
#[error("entity name is empty")]
Empty,
#[error("entity name length {len} exceeds max {max}")]
TooLong { len: usize, max: usize },
#[error("entity name must be ASCII")]
NonAscii,
#[error("entity name must not contain '|'")]
Delimiter,
}
#[derive(Debug, ThisError)]
pub enum IndexNameError {
#[error("index has {len} fields (max {max})")]
TooManyFields { len: usize, max: usize },
#[error("index must reference at least one field")]
NoFields,
#[error("index field name is empty")]
FieldEmpty,
#[error("index field name '{field}' exceeds max length {max}")]
FieldTooLong { field: String, max: usize },
#[error("index field name '{field}' must be ASCII")]
FieldNonAscii { field: String },
#[error("index field name '{field}' must not contain '|'")]
FieldDelimiter { field: String },
#[error("index name length {len} exceeds max {max}")]
TooLong { len: usize, max: usize },
}
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub struct EntityName {
len: u8,
bytes: [u8; MAX_ENTITY_NAME_LEN],
}
impl EntityName {
pub const STORED_SIZE_BYTES: u64 = 1 + (MAX_ENTITY_NAME_LEN as u64);
pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
pub fn try_from_str(name: &str) -> Result<Self, EntityNameError> {
let bytes = name.as_bytes();
let len = bytes.len();
if len == 0 {
return Err(EntityNameError::Empty);
}
if len > MAX_ENTITY_NAME_LEN {
return Err(EntityNameError::TooLong {
len,
max: MAX_ENTITY_NAME_LEN,
});
}
if !bytes.is_ascii() {
return Err(EntityNameError::NonAscii);
}
if bytes.contains(&INDEX_NAME_SEGMENT_DELIMITER) {
return Err(EntityNameError::Delimiter);
}
let mut out = [0u8; MAX_ENTITY_NAME_LEN];
out[..len].copy_from_slice(bytes);
Ok(Self {
len: len as u8,
bytes: out,
})
}
#[must_use]
pub const fn len(&self) -> usize {
self.len as usize
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.bytes[..self.len()]
}
#[must_use]
pub fn as_str(&self) -> &str {
std::str::from_utf8(self.as_bytes()).expect("EntityName invariant: ASCII-only storage")
}
#[must_use]
pub fn to_bytes(self) -> [u8; Self::STORED_SIZE_USIZE] {
let mut out = [0u8; Self::STORED_SIZE_USIZE];
out[0] = self.len;
out[1..].copy_from_slice(&self.bytes);
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityDecodeError> {
if bytes.len() != Self::STORED_SIZE_USIZE {
return Err(IdentityDecodeError::InvalidSize);
}
let len = bytes[0] as usize;
if len == 0 || len > MAX_ENTITY_NAME_LEN {
return Err(IdentityDecodeError::InvalidLength);
}
if !bytes[1..=len].is_ascii() {
return Err(IdentityDecodeError::NonAscii);
}
if bytes[1..=len].contains(&INDEX_NAME_SEGMENT_DELIMITER) {
return Err(IdentityDecodeError::Delimiter);
}
if bytes[1 + len..].iter().any(|&b| b != 0) {
return Err(IdentityDecodeError::NonZeroPadding);
}
let mut name = [0u8; MAX_ENTITY_NAME_LEN];
name.copy_from_slice(&bytes[1..]);
Ok(Self {
len: len as u8,
bytes: name,
})
}
#[must_use]
pub const fn max_storable() -> Self {
Self {
len: MAX_ENTITY_NAME_LEN as u8,
bytes: [MAX_ASCII_BYTE; MAX_ENTITY_NAME_LEN],
}
}
}
impl Ord for EntityName {
fn cmp(&self, other: &Self) -> Ordering {
self.len.cmp(&other.len).then(self.bytes.cmp(&other.bytes))
}
}
impl PartialOrd for EntityName {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Display for EntityName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for EntityName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "EntityName({})", self.as_str())
}
}
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub struct IndexName {
len: u16,
bytes: [u8; MAX_INDEX_NAME_LEN],
}
impl IndexName {
pub const STORED_SIZE_BYTES: u64 = 2 + (MAX_INDEX_NAME_LEN as u64);
pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
pub fn try_from_parts(entity: &EntityName, fields: &[&str]) -> Result<Self, IndexNameError> {
Self::try_from_parts_with_prefix("idx", entity, fields)
}
pub fn try_unique_from_parts(
entity: &EntityName,
fields: &[&str],
) -> Result<Self, IndexNameError> {
Self::try_from_parts_with_prefix("uniq", entity, fields)
}
fn try_from_parts_with_prefix(
prefix: &str,
entity: &EntityName,
fields: &[&str],
) -> Result<Self, IndexNameError> {
if fields.is_empty() {
return Err(IndexNameError::NoFields);
}
if fields.len() > MAX_INDEX_FIELDS {
return Err(IndexNameError::TooManyFields {
len: fields.len(),
max: MAX_INDEX_FIELDS,
});
}
let mut field_slugs = Vec::with_capacity(fields.len());
for field in fields {
let field_len = field.len();
if field_len == 0 {
return Err(IndexNameError::FieldEmpty);
}
if field_len > MAX_INDEX_FIELD_NAME_LEN {
return Err(IndexNameError::FieldTooLong {
field: (*field).to_string(),
max: MAX_INDEX_FIELD_NAME_LEN,
});
}
if !field.is_ascii() {
return Err(IndexNameError::FieldNonAscii {
field: (*field).to_string(),
});
}
if field.as_bytes().contains(&INDEX_NAME_SEGMENT_DELIMITER) {
return Err(IndexNameError::FieldDelimiter {
field: (*field).to_string(),
});
}
let slug = index_name_slug(field);
if slug.is_empty() {
return Err(IndexNameError::FieldEmpty);
}
field_slugs.push(slug);
}
let entity_slug = index_name_slug(entity.as_str());
let total_len = prefix
.len()
.saturating_add(1)
.saturating_add(entity_slug.len())
.saturating_add(2)
.saturating_add(field_slugs.iter().map(String::len).sum::<usize>())
.saturating_add(field_slugs.len().saturating_sub(1));
if total_len > MAX_INDEX_NAME_LEN {
return Err(IndexNameError::TooLong {
len: total_len,
max: MAX_INDEX_NAME_LEN,
});
}
let mut out = [0u8; MAX_INDEX_NAME_LEN];
let mut len = 0usize;
Self::push_bytes(&mut out, &mut len, prefix.as_bytes());
Self::push_bytes(&mut out, &mut len, b"_");
Self::push_bytes(&mut out, &mut len, entity_slug.as_bytes());
Self::push_bytes(&mut out, &mut len, b"__");
for (index, field_slug) in field_slugs.iter().enumerate() {
if index > 0 {
Self::push_bytes(&mut out, &mut len, b"_");
}
Self::push_bytes(&mut out, &mut len, field_slug.as_bytes());
}
Ok(Self {
len: len as u16,
bytes: out,
})
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.bytes[..self.len as usize]
}
#[must_use]
pub fn as_str(&self) -> &str {
std::str::from_utf8(self.as_bytes()).expect("IndexName invariant: ASCII-only storage")
}
#[must_use]
pub fn to_bytes(self) -> [u8; Self::STORED_SIZE_USIZE] {
let mut out = [0u8; Self::STORED_SIZE_USIZE];
out[..2].copy_from_slice(&self.len.to_be_bytes());
out[2..].copy_from_slice(&self.bytes);
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityDecodeError> {
if bytes.len() != Self::STORED_SIZE_USIZE {
return Err(IdentityDecodeError::InvalidSize);
}
let len = u16::from_be_bytes([bytes[0], bytes[1]]) as usize;
if len == 0 || len > MAX_INDEX_NAME_LEN {
return Err(IdentityDecodeError::InvalidLength);
}
if !bytes[2..2 + len].is_ascii() {
return Err(IdentityDecodeError::NonAscii);
}
if bytes[2 + len..].iter().any(|&b| b != 0) {
return Err(IdentityDecodeError::NonZeroPadding);
}
let mut name = [0u8; MAX_INDEX_NAME_LEN];
name.copy_from_slice(&bytes[2..]);
Ok(Self {
len: len as u16,
bytes: name,
})
}
fn push_bytes(out: &mut [u8; MAX_INDEX_NAME_LEN], len: &mut usize, bytes: &[u8]) {
let end = *len + bytes.len();
out[*len..end].copy_from_slice(bytes);
*len = end;
}
#[must_use]
pub const fn max_storable() -> Self {
Self {
len: MAX_INDEX_NAME_LEN as u16,
bytes: [MAX_ASCII_BYTE; MAX_INDEX_NAME_LEN],
}
}
}
fn index_name_slug(value: &str) -> String {
let separated = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
.collect::<String>();
to_snake_case(separated.as_str())
}
impl Ord for IndexName {
fn cmp(&self, other: &Self) -> Ordering {
self.to_bytes().cmp(&other.to_bytes())
}
}
impl PartialOrd for IndexName {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Debug for IndexName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "IndexName({})", self.as_str())
}
}
impl Display for IndexName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}