use core::fmt;
#[cfg(not(feature = "std"))]
use alloc::borrow::Cow;
#[cfg(feature = "std")]
use std::borrow::Cow;
use crate::error::KeyParseError;
use crate::key::DEFAULT_MAX_KEY_LENGTH;
pub trait Domain: 'static + Send + Sync + fmt::Debug {
const DOMAIN_NAME: &'static str;
}
pub trait IdDomain: Domain {}
#[cfg(feature = "uuid")]
pub trait UuidDomain: Domain {}
#[cfg(feature = "ulid")]
pub trait UlidDomain: Domain {
const PREFIX: &'static str;
}
pub trait KeyDomain: Domain {
const MAX_LENGTH: usize = DEFAULT_MAX_KEY_LENGTH;
const HAS_CUSTOM_VALIDATION: bool = false;
const HAS_CUSTOM_NORMALIZATION: bool = false;
const EXPECTED_LENGTH: usize = 32;
const TYPICALLY_SHORT: bool = true;
const FREQUENTLY_COMPARED: bool = false;
const FREQUENTLY_SPLIT: bool = false;
const CASE_INSENSITIVE: bool = false;
fn validate_domain_rules(_key: &str) -> Result<(), KeyParseError> {
Ok(()) }
#[must_use]
fn allowed_characters(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
}
#[must_use]
fn normalize_domain(key: Cow<'_, str>) -> Cow<'_, str> {
key }
#[must_use]
fn is_reserved_prefix(_key: &str) -> bool {
false }
#[must_use]
fn is_reserved_suffix(_key: &str) -> bool {
false }
#[must_use]
fn validation_help() -> Option<&'static str> {
None }
#[must_use]
fn examples() -> &'static [&'static str] {
&[] }
#[must_use]
fn default_separator() -> char {
'_' }
#[must_use]
fn requires_ascii_only() -> bool {
false }
#[must_use]
fn min_length() -> usize {
1 }
#[must_use]
fn allowed_start_character(c: char) -> bool {
Self::allowed_characters(c) && c != '_' && c != '-' && c != '.'
}
#[must_use]
fn allowed_end_character(c: char) -> bool {
Self::allowed_characters(c) && c != '_' && c != '-' && c != '.'
}
#[must_use]
fn allowed_consecutive_characters(prev: char, curr: char) -> bool {
!(prev == curr && (prev == '_' || prev == '-' || prev == '.'))
}
}
#[expect(
clippy::struct_excessive_bools,
reason = "DomainInfo needs all boolean flags for its introspection API"
)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DomainInfo {
pub name: &'static str,
pub max_length: usize,
pub min_length: usize,
pub expected_length: usize,
pub typically_short: bool,
pub frequently_compared: bool,
pub frequently_split: bool,
pub case_insensitive: bool,
pub has_custom_validation: bool,
pub has_custom_normalization: bool,
pub default_separator: char,
pub validation_help: Option<&'static str>,
pub examples: &'static [&'static str],
}
impl fmt::Display for DomainInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Domain: {}", self.name)?;
writeln!(
f,
"Length: {}-{} (expected: {})",
self.min_length, self.max_length, self.expected_length
)?;
writeln!(f, "Optimization hints:")?;
writeln!(f, " • Typically short: {}", self.typically_short)?;
writeln!(f, " • Frequently compared: {}", self.frequently_compared)?;
writeln!(f, " • Frequently split: {}", self.frequently_split)?;
writeln!(f, " • Case insensitive: {}", self.case_insensitive)?;
writeln!(f, "Custom features:")?;
writeln!(f, " • Custom validation: {}", self.has_custom_validation)?;
writeln!(
f,
" • Custom normalization: {}",
self.has_custom_normalization
)?;
writeln!(f, "Default separator: '{}'", self.default_separator)?;
if let Some(help) = self.validation_help {
writeln!(f, "Validation help: {help}")?;
}
if !self.examples.is_empty() {
writeln!(f, "Examples: {:?}", self.examples)?;
}
Ok(())
}
}
#[must_use]
pub fn domain_info<T: KeyDomain>() -> DomainInfo {
DomainInfo {
name: T::DOMAIN_NAME,
max_length: T::MAX_LENGTH,
min_length: T::min_length(),
expected_length: T::EXPECTED_LENGTH,
typically_short: T::TYPICALLY_SHORT,
frequently_compared: T::FREQUENTLY_COMPARED,
frequently_split: T::FREQUENTLY_SPLIT,
case_insensitive: T::CASE_INSENSITIVE,
has_custom_validation: T::HAS_CUSTOM_VALIDATION,
has_custom_normalization: T::HAS_CUSTOM_NORMALIZATION,
default_separator: T::default_separator(),
validation_help: T::validation_help(),
examples: T::examples(),
}
}
#[must_use]
pub fn domains_compatible<T1: KeyDomain, T2: KeyDomain>() -> bool {
T1::MAX_LENGTH == T2::MAX_LENGTH
&& T1::CASE_INSENSITIVE == T2::CASE_INSENSITIVE
&& T1::default_separator() == T2::default_separator()
}
#[derive(Debug)]
pub struct DefaultDomain;
impl Domain for DefaultDomain {
const DOMAIN_NAME: &'static str = "default";
}
impl KeyDomain for DefaultDomain {
const MAX_LENGTH: usize = 64;
const EXPECTED_LENGTH: usize = 24;
const TYPICALLY_SHORT: bool = true;
const CASE_INSENSITIVE: bool = true;
fn validation_help() -> Option<&'static str> {
Some("Use alphanumeric characters, underscores, hyphens, and dots. Case insensitive.")
}
fn examples() -> &'static [&'static str] {
&["user_123", "session-abc", "cache.key", "simple"]
}
}
#[derive(Debug)]
pub struct IdentifierDomain;
impl Domain for IdentifierDomain {
const DOMAIN_NAME: &'static str = "identifier";
}
impl KeyDomain for IdentifierDomain {
const MAX_LENGTH: usize = 64;
const EXPECTED_LENGTH: usize = 20;
const TYPICALLY_SHORT: bool = true;
const CASE_INSENSITIVE: bool = false;
const HAS_CUSTOM_VALIDATION: bool = true;
fn allowed_characters(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn allowed_start_character(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn validate_domain_rules(key: &str) -> Result<(), KeyParseError> {
if let Some(first) = key.chars().next() {
if !Self::allowed_start_character(first) {
return Err(KeyParseError::domain_error(
Self::DOMAIN_NAME,
"Identifier must start with a letter or underscore",
));
}
}
Ok(())
}
fn validation_help() -> Option<&'static str> {
Some("Must start with letter or underscore, contain only letters, numbers, and underscores. Case sensitive.")
}
fn examples() -> &'static [&'static str] {
&["user_id", "session_key", "_private", "publicVar"]
}
}
#[derive(Debug)]
pub struct PathDomain;
impl Domain for PathDomain {
const DOMAIN_NAME: &'static str = "path";
}
impl KeyDomain for PathDomain {
const MAX_LENGTH: usize = 256;
const EXPECTED_LENGTH: usize = 48;
const TYPICALLY_SHORT: bool = false;
const CASE_INSENSITIVE: bool = true;
const FREQUENTLY_SPLIT: bool = true;
const HAS_CUSTOM_VALIDATION: bool = true;
fn allowed_characters(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/'
}
fn allowed_start_character(c: char) -> bool {
Self::allowed_characters(c) && c != '/'
}
fn allowed_end_character(c: char) -> bool {
Self::allowed_characters(c) && c != '/'
}
fn allowed_consecutive_characters(prev: char, curr: char) -> bool {
!(prev == '/' && curr == '/')
}
fn default_separator() -> char {
'/'
}
fn validate_domain_rules(_key: &str) -> Result<(), KeyParseError> {
Ok(())
}
fn validation_help() -> Option<&'static str> {
Some("Use path-like format with '/' separators. Cannot start/end with '/' or have consecutive '//'.")
}
fn examples() -> &'static [&'static str] {
&["users/profile", "cache/session/data", "config/app.settings"]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "std"))]
use alloc::borrow::Cow;
#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::string::ToString;
#[cfg(feature = "std")]
use std::borrow::Cow;
#[test]
fn default_domain_is_case_insensitive_with_max_64() {
let info = domain_info::<DefaultDomain>();
assert_eq!(info.name, "default");
assert_eq!(info.max_length, 64);
assert!(info.case_insensitive);
assert!(!info.has_custom_validation);
}
#[test]
fn identifier_domain_rejects_hyphens_and_leading_digits() {
let info = domain_info::<IdentifierDomain>();
assert_eq!(info.name, "identifier");
assert!(!info.case_insensitive);
assert!(info.has_custom_validation);
assert!(IdentifierDomain::allowed_characters('a'));
assert!(IdentifierDomain::allowed_characters('_'));
assert!(!IdentifierDomain::allowed_characters('-'));
assert!(IdentifierDomain::allowed_start_character('a'));
assert!(IdentifierDomain::allowed_start_character('_'));
assert!(!IdentifierDomain::allowed_start_character('1'));
}
#[test]
fn path_domain_allows_slashes_but_not_consecutive() {
let info = domain_info::<PathDomain>();
assert_eq!(info.name, "path");
assert_eq!(info.default_separator, '/');
assert!(info.frequently_split);
assert!(info.has_custom_validation);
assert!(PathDomain::allowed_characters('/'));
assert!(!PathDomain::allowed_start_character('/'));
assert!(!PathDomain::allowed_end_character('/'));
assert!(!PathDomain::allowed_consecutive_characters('/', '/'));
}
#[test]
fn domain_info_display_includes_name_and_length() {
let info = domain_info::<DefaultDomain>();
let display = format!("{info}");
assert!(display.contains("Domain: default"));
assert!(display.contains("Length: 1-64"));
assert!(display.contains("Case insensitive: true"));
}
#[test]
fn compatible_domains_share_config_incompatible_differ() {
assert!(domains_compatible::<DefaultDomain, DefaultDomain>());
assert!(!domains_compatible::<DefaultDomain, IdentifierDomain>());
assert!(!domains_compatible::<IdentifierDomain, PathDomain>());
}
#[test]
fn default_trait_methods_return_sensible_defaults() {
assert!(DefaultDomain::allowed_characters('a'));
assert!(!DefaultDomain::is_reserved_prefix("test"));
assert!(!DefaultDomain::is_reserved_suffix("test"));
assert!(!DefaultDomain::requires_ascii_only());
assert_eq!(DefaultDomain::min_length(), 1);
assert!(DefaultDomain::validation_help().is_some());
assert!(!DefaultDomain::examples().is_empty());
}
#[test]
fn normalize_domain_borrows_when_unchanged() {
let input = Cow::Borrowed("test");
let output = DefaultDomain::normalize_domain(input);
assert!(matches!(output, Cow::Borrowed("test")));
let input = Cow::Owned("test".to_string());
let output = DefaultDomain::normalize_domain(input);
assert!(matches!(output, Cow::Owned(_)));
}
}