Skip to main content

use_identifier/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! String-backed identifier primitives.
5
6use core::{fmt, marker::PhantomData};
7
8pub mod prelude;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum IdentifierError {
12    Empty,
13    InvalidCharacter { character: char, index: usize },
14}
15
16impl fmt::Display for IdentifierError {
17    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Empty => formatter.write_str("identifier cannot be empty"),
20            Self::InvalidCharacter { character, index } => {
21                write!(
22                    formatter,
23                    "invalid identifier character `{character}` at byte {index}"
24                )
25            },
26        }
27    }
28}
29
30impl std::error::Error for IdentifierError {}
31
32#[must_use]
33pub fn normalize_identifier(input: &str) -> String {
34    input.trim().to_owned()
35}
36
37/// Validates that an identifier is non-empty and contains only supported ASCII characters.
38///
39/// # Errors
40///
41/// Returns [`IdentifierError::Empty`] when the input is empty and
42/// [`IdentifierError::InvalidCharacter`] when it contains unsupported characters.
43pub fn validate_identifier(input: &str) -> Result<(), IdentifierError> {
44    if input.is_empty() {
45        return Err(IdentifierError::Empty);
46    }
47
48    for (index, character) in input.char_indices() {
49        if !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')) {
50            return Err(IdentifierError::InvalidCharacter { character, index });
51        }
52    }
53
54    Ok(())
55}
56
57#[must_use]
58pub fn is_valid_identifier(input: &str) -> bool {
59    validate_identifier(input).is_ok()
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
63pub struct Identifier(String);
64
65impl Identifier {
66    /// Creates a validated identifier from user-provided text.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`IdentifierError::Empty`] when the trimmed input is empty and
71    /// [`IdentifierError::InvalidCharacter`] when it contains unsupported characters.
72    pub fn new(input: &str) -> Result<Self, IdentifierError> {
73        let normalized = normalize_identifier(input);
74        validate_identifier(&normalized)?;
75        Ok(Self(normalized))
76    }
77
78    #[must_use]
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82
83    #[must_use]
84    pub fn into_inner(self) -> String {
85        self.0
86    }
87}
88
89impl fmt::Display for Identifier {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        formatter.write_str(self.as_str())
92    }
93}
94
95pub trait IdentifierKind {
96    const NAME: &'static str;
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Hash)]
100pub struct TypedIdentifier<K> {
101    identifier: Identifier,
102    marker: PhantomData<fn() -> K>,
103}
104
105impl<K> TypedIdentifier<K> {
106    #[must_use]
107    pub const fn from_identifier(identifier: Identifier) -> Self {
108        Self {
109            identifier,
110            marker: PhantomData,
111        }
112    }
113
114    #[must_use]
115    pub const fn as_identifier(&self) -> &Identifier {
116        &self.identifier
117    }
118
119    #[must_use]
120    pub fn as_str(&self) -> &str {
121        self.identifier.as_str()
122    }
123
124    #[must_use]
125    pub fn into_inner(self) -> Identifier {
126        self.identifier
127    }
128}
129
130impl<K: IdentifierKind> TypedIdentifier<K> {
131    /// Creates a typed identifier from user-provided text.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`IdentifierError::Empty`] when the trimmed input is empty and
136    /// [`IdentifierError::InvalidCharacter`] when it contains unsupported characters.
137    pub fn new(input: &str) -> Result<Self, IdentifierError> {
138        Identifier::new(input).map(Self::from_identifier)
139    }
140
141    #[must_use]
142    pub const fn kind_name(&self) -> &'static str {
143        K::NAME
144    }
145}
146
147impl<K> fmt::Display for TypedIdentifier<K> {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        self.identifier.fmt(formatter)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{Identifier, IdentifierError, IdentifierKind, TypedIdentifier};
156
157    struct User;
158
159    impl IdentifierKind for User {
160        const NAME: &'static str = "user";
161    }
162
163    #[test]
164    fn trims_and_preserves_valid_identifiers() -> Result<(), IdentifierError> {
165        let identifier = Identifier::new("  acct_42  ")?;
166        assert_eq!(identifier.as_str(), "acct_42");
167        Ok(())
168    }
169
170    #[test]
171    fn rejects_invalid_characters() {
172        assert_eq!(
173            Identifier::new("bad value"),
174            Err(IdentifierError::InvalidCharacter {
175                character: ' ',
176                index: 3,
177            })
178        );
179    }
180
181    #[test]
182    fn typed_identifier_carries_kind_name() -> Result<(), IdentifierError> {
183        let typed = TypedIdentifier::<User>::new("usr_7")?;
184        assert_eq!(typed.kind_name(), "user");
185        Ok(())
186    }
187}