maytrix_value/
symbol.rs

1/// A validated identifier following the pattern `^[a-z][a-z0-9_]*$`.
2///
3/// `Symbol` ensures its inner value is a well-formed, lowercase ASCII identifier
4/// commonly used for names, keys, or codes. It provides efficient comparison
5/// and map/set usage by implementing `Eq`, `Ord`, and `Hash` and supports
6/// borrowing as `&str`.
7///
8/// # Examples
9///
10/// Creating a valid `Symbol`:
11///
12/// ```
13/// use maytrix_value::Symbol;
14/// let sym = Symbol::try_new("alpha_1").unwrap();
15/// assert_eq!(sym.as_str(), "alpha_1");
16/// ```
17///
18/// Invalid values yield an error:
19///
20/// ```
21/// use maytrix_value::Symbol;
22/// assert!(Symbol::try_new("Bad-Name").is_err());
23/// ```
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct Symbol {
26    value: String,
27}
28
29impl Symbol {
30    /// Attempts to construct a `Symbol` from a string-like value.
31    ///
32    /// The input must match the regex `^[a-z][a-z0-9_]*$`.
33    ///
34    /// # Examples
35    ///
36    /// Successful creation:
37    /// ```
38    /// use maytrix_value::Symbol;
39    /// let s = Symbol::try_new("task1").unwrap();
40    /// assert_eq!(s, "task1");
41    /// ```
42    ///
43    /// Failure on invalid input:
44    /// ```
45    /// use maytrix_value::Symbol;
46    /// assert!(Symbol::try_new("1bad").is_err());
47    /// ```
48    pub fn try_new<S: Into<String>>(value: S) -> Result<Self, SymbolError> {
49        let s = value.into();
50        if Self::is_valid(&s) {
51            Ok(Self { value: s })
52        } else {
53            Err(SymbolError)
54        }
55    }
56
57    /// Returns the inner string slice.
58    ///
59    /// This is equivalent to dereferencing `Symbol` to `&str`.
60    ///
61    /// ```
62    /// use maytrix_value::Symbol;
63    /// let s = Symbol::try_new("ok").unwrap();
64    /// assert_eq!(s.as_str(), "ok");
65    /// assert_eq!(&*s, "ok"); // Deref to str
66    /// ```
67    pub fn as_str(&self) -> &str {
68        &self.value
69    }
70
71    /// Returns true if the provided string matches `^[a-z][a-z0-9_]*$`.
72    ///
73    /// This is a pure validator that does not allocate.
74    ///
75    /// ```
76    /// use maytrix_value::Symbol;
77    /// assert!(Symbol::is_valid("a"));
78    /// assert!(Symbol::is_valid("a0_b"));
79    /// assert!(!Symbol::is_valid("_bad"));
80    /// assert!(!Symbol::is_valid("Nope"));
81    /// ```
82    pub fn is_valid(s: &str) -> bool {
83        let mut chars = s.chars();
84        match chars.next() {
85            Some(first) if first.is_ascii_lowercase() => {}
86            _ => return false,
87        }
88        for c in chars {
89            if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
90                return false;
91            }
92        }
93        true
94    }
95}
96
97impl core::fmt::Display for Symbol {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        self.value.fmt(f)
100    }
101}
102
103impl core::ops::Deref for Symbol {
104    type Target = str;
105    fn deref(&self) -> &Self::Target {
106        self.as_str()
107    }
108}
109
110impl core::str::FromStr for Symbol {
111    type Err = SymbolError;
112    fn from_str(s: &str) -> Result<Self, Self::Err> {
113        Symbol::try_new(s)
114    }
115}
116
117impl TryFrom<&str> for Symbol {
118    type Error = SymbolError;
119    fn try_from(value: &str) -> Result<Self, Self::Error> {
120        Symbol::try_new(value)
121    }
122}
123
124impl TryFrom<String> for Symbol {
125    type Error = SymbolError;
126    fn try_from(value: String) -> Result<Self, Self::Error> {
127        Symbol::try_new(value)
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SymbolError;
133
134impl core::fmt::Display for SymbolError {
135    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136        write!(f, "value must match ^[a-z][a-z0-9_]*$")
137    }
138}
139
140impl std::error::Error for SymbolError {}
141
142impl AsRef<str> for Symbol {
143    fn as_ref(&self) -> &str {
144        self.as_str()
145    }
146}
147
148impl core::borrow::Borrow<str> for Symbol {
149    fn borrow(&self) -> &str {
150        self.as_str()
151    }
152}
153
154impl core::cmp::PartialOrd for Symbol {
155    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
156        Some(self.as_str().cmp(other.as_str()))
157    }
158}
159
160impl core::cmp::Ord for Symbol {
161    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
162        self.as_str().cmp(other.as_str())
163    }
164}
165
166impl From<Symbol> for String {
167    fn from(s: Symbol) -> Self {
168        s.value
169    }
170}
171
172impl From<Symbol> for Box<str> {
173    fn from(s: Symbol) -> Self {
174        s.value.into_boxed_str()
175    }
176}
177
178// Optional ergonomic cross-type equality
179impl PartialEq<&str> for Symbol {
180    fn eq(&self, other: &&str) -> bool {
181        self.as_str() == *other
182    }
183}
184impl PartialEq<Symbol> for &str {
185    fn eq(&self, other: &Symbol) -> bool {
186        *self == other.as_str()
187    }
188}
189impl PartialEq<String> for Symbol {
190    fn eq(&self, other: &String) -> bool {
191        self.as_str() == other.as_str()
192    }
193}
194impl PartialEq<Symbol> for String {
195    fn eq(&self, other: &Symbol) -> bool {
196        self.as_str() == other.as_str()
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    use core::str::FromStr;
205
206    #[test]
207    fn is_valid_accepts_simple_lowercase() {
208        assert!(Symbol::is_valid("a"));
209        assert!(Symbol::is_valid("abc"));
210        assert!(Symbol::is_valid("z"));
211    }
212
213    #[test]
214    fn is_valid_accepts_digits_and_underscores_after_first() {
215        assert!(Symbol::is_valid("a1"));
216        assert!(Symbol::is_valid("a_b"));
217        assert!(Symbol::is_valid("a1_b2_c3"));
218        assert!(Symbol::is_valid("a0_9"));
219        assert!(Symbol::is_valid("a__"));
220    }
221
222    #[test]
223    fn is_valid_rejects_empty_and_bad_first_char() {
224        assert!(!Symbol::is_valid(""));
225        assert!(!Symbol::is_valid("1abc"));
226        assert!(!Symbol::is_valid("_abc"));
227        assert!(!Symbol::is_valid("A"));
228    }
229
230    #[test]
231    fn is_valid_rejects_invalid_tail_chars() {
232        assert!(!Symbol::is_valid("a-"));
233        assert!(!Symbol::is_valid("a-1"));
234        assert!(!Symbol::is_valid("a b"));
235        assert!(!Symbol::is_valid("a$"));
236        assert!(!Symbol::is_valid("aB")); // uppercase after first not allowed either
237        assert!(!Symbol::is_valid("aÄ")); // non-ascii letter
238    }
239
240    #[test]
241    fn try_new_constructs_for_valid_and_errors_for_invalid() {
242        let ok = Symbol::try_new("abc_123");
243        assert!(ok.is_ok());
244        assert_eq!(ok.unwrap().as_str(), "abc_123");
245
246        let err = Symbol::try_new("-bad");
247        assert!(err.is_err());
248    }
249
250    #[test]
251    fn display_and_deref_expose_inner() {
252        let s = Symbol::try_new("abc_123").unwrap();
253        assert_eq!(&*s, "abc_123"); // Deref<str>
254        assert_eq!(s.as_str(), "abc_123");
255        assert_eq!(s.to_string(), "abc_123");
256    }
257
258    #[test]
259    fn from_str_and_try_from_work() {
260        let s1 = Symbol::from_str("name1").unwrap();
261        assert_eq!(s1, "name1");
262
263        let s2: Result<Symbol, _> = "x_y".try_into();
264        assert_eq!(s2.unwrap(), "x_y");
265
266        let s3: Result<Symbol, _> = String::from("ok_2").try_into();
267        assert_eq!(s3.unwrap(), "ok_2");
268
269        let bad: Result<Symbol, _> = "Nope".try_into();
270        assert!(bad.is_err());
271    }
272
273    #[test]
274    fn error_display_message_matches_spec() {
275        let err = Symbol::try_new("Bad-Name").unwrap_err();
276        assert_eq!(err.to_string(), "value must match ^[a-z][a-z0-9_]*$");
277    }
278
279    #[test]
280    fn equality_and_hash_semantics() {
281        use std::collections::HashSet;
282        let a = Symbol::try_new("abc").unwrap();
283        let b = Symbol::try_new("abc").unwrap();
284        let c = Symbol::try_new("abd").unwrap();
285
286        assert_eq!(a, b);
287        assert_ne!(a, c);
288
289        let mut set = HashSet::new();
290        set.insert(a);
291        assert!(set.contains(&b));
292        assert!(!set.contains(&c));
293        // Borrow<str> enables contains lookup by &str in HashSet as well
294        assert!(set.contains("abc"));
295        assert!(!set.contains("abd"));
296    }
297
298    #[test]
299    fn as_ref_borrow_and_hashmap_lookup() {
300        use std::collections::HashMap;
301        let key = Symbol::try_new("alpha").unwrap();
302        let mut map = HashMap::new();
303        map.insert(key.clone(), 42);
304        // Lookup by &str thanks to Borrow<str>
305        assert_eq!(map.get("alpha"), Some(&42));
306
307        // AsRef<str>
308        fn takes_as_ref<S: AsRef<str>>(s: S) -> usize { s.as_ref().len() }
309        assert_eq!(takes_as_ref(&key), 5);
310    }
311
312    #[test]
313    fn ordering_and_btreeset() {
314        use std::collections::BTreeSet;
315        let inputs = ["beta", "alpha", "alpha_1", "alpha0"];
316        let mut syms: Vec<Symbol> = inputs.iter().map(|s| Symbol::try_new(*s).unwrap()).collect();
317        syms.sort(); // requires PartialOrd/Ord
318        let sorted: Vec<&str> = syms.iter().map(|s| s.as_str()).collect();
319        assert_eq!(sorted, vec!["alpha", "alpha0", "alpha_1", "beta"]);
320
321        let set: BTreeSet<Symbol> = syms.into_iter().collect();
322        let ordered: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
323        assert_eq!(ordered, vec!["alpha", "alpha0", "alpha_1", "beta"]);
324    }
325
326    #[test]
327    fn into_string_and_boxed_str() {
328        let s = Symbol::try_new("gamma").unwrap();
329        let owned: String = s.clone().into();
330        assert_eq!(owned, "gamma");
331        let boxed: Box<str> = s.clone().into();
332        assert_eq!(&*boxed, "gamma");
333    }
334
335    #[test]
336    fn cross_type_equality() {
337        let s = Symbol::try_new("delta_1").unwrap();
338        assert!(s == "delta_1");
339        assert!("delta_1" == s);
340        assert!(String::from("delta_1") == s);
341        assert!(s == String::from("delta_1"));
342        assert!(s != "delta2");
343    }
344}