Skip to main content

use_id_prefix/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Prefixed identifier helpers.
5
6use core::{fmt, marker::PhantomData};
7
8pub mod prelude;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum IdPrefixError {
12    EmptyPrefix,
13    InvalidPrefixCharacter {
14        character: char,
15        index: usize,
16    },
17    EmptyValue,
18    InvalidValueCharacter {
19        character: char,
20        index: usize,
21    },
22    MissingSeparator,
23    MismatchedPrefix {
24        expected: &'static str,
25        actual: String,
26    },
27}
28
29impl fmt::Display for IdPrefixError {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::EmptyPrefix => formatter.write_str("prefix cannot be empty"),
33            Self::InvalidPrefixCharacter { character, index } => {
34                write!(
35                    formatter,
36                    "invalid prefix character `{character}` at byte {index}"
37                )
38            },
39            Self::EmptyValue => formatter.write_str("identifier value cannot be empty"),
40            Self::InvalidValueCharacter { character, index } => {
41                write!(
42                    formatter,
43                    "invalid value character `{character}` at byte {index}"
44                )
45            },
46            Self::MissingSeparator => formatter.write_str("expected a `_` separator"),
47            Self::MismatchedPrefix { expected, actual } => {
48                write!(
49                    formatter,
50                    "expected prefix `{expected}` but found `{actual}`"
51                )
52            },
53        }
54    }
55}
56
57impl std::error::Error for IdPrefixError {}
58
59#[must_use]
60pub fn normalize_prefix(input: &str) -> String {
61    input.trim().to_ascii_lowercase()
62}
63
64/// Validates that a prefix starts with a lowercase ASCII letter and only contains lowercase
65/// ASCII letters or digits.
66///
67/// # Errors
68///
69/// Returns a [`IdPrefixError`] variant describing the first invalid prefix condition.
70pub fn validate_prefix(input: &str) -> Result<(), IdPrefixError> {
71    if input.is_empty() {
72        return Err(IdPrefixError::EmptyPrefix);
73    }
74
75    for (index, character) in input.char_indices() {
76        let is_valid = if index == 0 {
77            character.is_ascii_lowercase()
78        } else {
79            character.is_ascii_lowercase() || character.is_ascii_digit()
80        };
81
82        if !is_valid {
83            return Err(IdPrefixError::InvalidPrefixCharacter { character, index });
84        }
85    }
86
87    Ok(())
88}
89
90#[must_use]
91pub fn is_valid_prefix(input: &str) -> bool {
92    validate_prefix(&normalize_prefix(input)).is_ok()
93}
94
95fn validate_value(input: &str) -> Result<(), IdPrefixError> {
96    if input.is_empty() {
97        return Err(IdPrefixError::EmptyValue);
98    }
99
100    for (index, character) in input.char_indices() {
101        if !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')) {
102            return Err(IdPrefixError::InvalidValueCharacter { character, index });
103        }
104    }
105
106    Ok(())
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
110pub struct IdPrefix(String);
111
112impl IdPrefix {
113    /// Creates a validated normalized prefix.
114    ///
115    /// # Errors
116    ///
117    /// Returns a [`IdPrefixError`] variant when the prefix is empty or contains unsupported
118    /// characters.
119    pub fn new(input: &str) -> Result<Self, IdPrefixError> {
120        let normalized = normalize_prefix(input);
121        validate_prefix(&normalized)?;
122        Ok(Self(normalized))
123    }
124
125    #[must_use]
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129}
130
131impl fmt::Display for IdPrefix {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        formatter.write_str(self.as_str())
134    }
135}
136
137/// Formats a prefix and value into the canonical `prefix_value` representation.
138///
139/// # Errors
140///
141/// Returns [`IdPrefixError::EmptyValue`] when the trimmed value is empty and
142/// [`IdPrefixError::InvalidValueCharacter`] when it contains unsupported characters.
143pub fn format_prefixed_id(prefix: &IdPrefix, value: &str) -> Result<String, IdPrefixError> {
144    let normalized = value.trim();
145    validate_value(normalized)?;
146    Ok(format!("{}_{}", prefix.as_str(), normalized))
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct PrefixedId {
151    prefix: IdPrefix,
152    value: String,
153}
154
155impl PrefixedId {
156    /// Creates a prefixed identifier from a validated prefix and value.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`IdPrefixError::EmptyValue`] when the trimmed value is empty and
161    /// [`IdPrefixError::InvalidValueCharacter`] when it contains unsupported characters.
162    pub fn new(prefix: IdPrefix, value: &str) -> Result<Self, IdPrefixError> {
163        let normalized = value.trim().to_owned();
164        validate_value(&normalized)?;
165        Ok(Self {
166            prefix,
167            value: normalized,
168        })
169    }
170
171    /// Parses the canonical `prefix_value` representation.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`IdPrefixError::MissingSeparator`] when the input does not contain `_` and the
176    /// corresponding prefix or value validation error for malformed parts.
177    pub fn parse(input: &str) -> Result<Self, IdPrefixError> {
178        let Some((prefix, value)) = input.split_once('_') else {
179            return Err(IdPrefixError::MissingSeparator);
180        };
181
182        Self::new(IdPrefix::new(prefix)?, value)
183    }
184
185    #[must_use]
186    pub const fn prefix(&self) -> &IdPrefix {
187        &self.prefix
188    }
189
190    #[must_use]
191    pub fn value(&self) -> &str {
192        &self.value
193    }
194
195    #[must_use]
196    pub fn into_parts(self) -> (IdPrefix, String) {
197        (self.prefix, self.value)
198    }
199}
200
201impl fmt::Display for PrefixedId {
202    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203        formatter.write_str(&format!("{}_{}", self.prefix, self.value))
204    }
205}
206
207pub trait PrefixedIdentifierKind {
208    const PREFIX: &'static str;
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Hash)]
212pub struct TypedPrefixedId<K> {
213    prefixed_id: PrefixedId,
214    marker: PhantomData<fn() -> K>,
215}
216
217impl<K> TypedPrefixedId<K> {
218    #[must_use]
219    pub const fn as_prefixed_id(&self) -> &PrefixedId {
220        &self.prefixed_id
221    }
222
223    #[must_use]
224    pub fn value(&self) -> &str {
225        self.prefixed_id.value()
226    }
227}
228
229impl<K: PrefixedIdentifierKind> TypedPrefixedId<K> {
230    /// Creates a typed prefixed identifier using the type-level prefix.
231    ///
232    /// # Errors
233    ///
234    /// Returns the underlying [`IdPrefixError`] when the type-level prefix or value is invalid.
235    pub fn new(value: &str) -> Result<Self, IdPrefixError> {
236        let prefixed_id = PrefixedId::new(IdPrefix::new(K::PREFIX)?, value)?;
237        Ok(Self {
238            prefixed_id,
239            marker: PhantomData,
240        })
241    }
242
243    /// Parses a typed prefixed identifier and verifies that the parsed prefix matches the type.
244    ///
245    /// # Errors
246    ///
247    /// Returns the underlying parsing error or [`IdPrefixError::MismatchedPrefix`] when the
248    /// parsed prefix does not match the type-level prefix.
249    pub fn parse(input: &str) -> Result<Self, IdPrefixError> {
250        let prefixed_id = PrefixedId::parse(input)?;
251
252        if prefixed_id.prefix().as_str() != K::PREFIX {
253            return Err(IdPrefixError::MismatchedPrefix {
254                expected: K::PREFIX,
255                actual: prefixed_id.prefix().as_str().to_owned(),
256            });
257        }
258
259        Ok(Self {
260            prefixed_id,
261            marker: PhantomData,
262        })
263    }
264
265    #[must_use]
266    pub const fn prefix(&self) -> &'static str {
267        K::PREFIX
268    }
269}
270
271impl<K> fmt::Display for TypedPrefixedId<K> {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        self.prefixed_id.fmt(formatter)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::{IdPrefixError, PrefixedId, PrefixedIdentifierKind, TypedPrefixedId};
280
281    struct User;
282
283    impl PrefixedIdentifierKind for User {
284        const PREFIX: &'static str = "usr";
285    }
286
287    #[test]
288    fn parses_prefixed_ids() -> Result<(), IdPrefixError> {
289        let prefixed = PrefixedId::parse("usr_123")?;
290        assert_eq!(prefixed.prefix().as_str(), "usr");
291        assert_eq!(prefixed.value(), "123");
292        Ok(())
293    }
294
295    #[test]
296    fn typed_ids_enforce_the_expected_prefix() -> Result<(), IdPrefixError> {
297        let typed = TypedPrefixedId::<User>::parse("usr_123")?;
298        assert_eq!(typed.prefix(), "usr");
299        Ok(())
300    }
301}