requiem/domain/
hrid.rs

1use std::{fmt, num::NonZeroUsize, ops::Deref, str::FromStr};
2
3use non_empty_string::NonEmptyString;
4
5/// A validated string containing only uppercase alphabetic characters ([A-Z]+).
6///
7/// Used for HRID kind and namespace segments to ensure they conform to the
8/// required format.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct KindString(NonEmptyString);
11
12impl KindString {
13    /// Creates a new `KindString` from a string.
14    ///
15    /// # Errors
16    ///
17    /// Returns `InvalidKindError` if the string is empty or contains
18    /// characters other than uppercase letters (A-Z).
19    pub fn new(s: String) -> Result<Self, InvalidKindError> {
20        // Check non-empty
21        let non_empty = NonEmptyString::new(s.clone()).map_err(|_| InvalidKindError(s.clone()))?;
22
23        // Check all characters are uppercase ASCII letters
24        if !s.chars().all(|c| c.is_ascii_uppercase()) {
25            return Err(InvalidKindError(s));
26        }
27
28        Ok(Self(non_empty))
29    }
30
31    /// Returns the string slice.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        self.0.as_str()
35    }
36}
37
38impl TryFrom<String> for KindString {
39    type Error = InvalidKindError;
40
41    fn try_from(value: String) -> Result<Self, Self::Error> {
42        Self::new(value)
43    }
44}
45
46impl TryFrom<&str> for KindString {
47    type Error = InvalidKindError;
48
49    fn try_from(value: &str) -> Result<Self, Self::Error> {
50        Self::new(value.to_string())
51    }
52}
53
54impl AsRef<str> for KindString {
55    fn as_ref(&self) -> &str {
56        self.0.as_str()
57    }
58}
59
60impl Deref for KindString {
61    type Target = str;
62
63    fn deref(&self) -> &Self::Target {
64        self.0.as_str()
65    }
66}
67
68impl fmt::Display for KindString {
69    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
70        write!(f, "{}", self.0)
71    }
72}
73
74impl FromStr for KindString {
75    type Err = InvalidKindError;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        Self::new(s.to_string())
79    }
80}
81
82/// Error returned when a string doesn't match the required pattern [A-Z]+.
83#[derive(Debug, thiserror::Error, PartialEq, Eq)]
84#[error("Invalid kind string '{0}': must be non-empty and contain only uppercase letters (A-Z)")]
85pub struct InvalidKindError(String);
86
87/// A human-readable identifier (HRID) for a requirement.
88///
89/// Format:
90/// `{NAMESPACE*}-{KIND}-{ID}`, where:
91/// - `NAMESPACE` is an optional sequence of uppercase alphabetic segments (e.g.
92///   `COMPONENT-SUBCOMPONENT`)
93/// - `KIND` is an uppercase alphabetic category string (e.g. `URS`, `SYS`)
94/// - `ID` is a positive non-zero integer (e.g. `001`, `123`)
95///
96/// Examples: `URS-001`, `SYS-099`, `COMPONENT-SUBCOMPONENT-SYS-005`
97#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
98pub struct Hrid {
99    namespace: Vec<KindString>,
100    kind: KindString,
101    id: NonZeroUsize,
102}
103
104impl Hrid {
105    /// Create an HRID with no namespace.
106    ///
107    /// This is an infallible constructor that takes pre-validated types.
108    #[must_use]
109    pub const fn new(kind: KindString, id: NonZeroUsize) -> Self {
110        Self::new_with_namespace(Vec::new(), kind, id)
111    }
112
113    /// Create an HRID with the given namespace.
114    ///
115    /// This is an infallible constructor that takes pre-validated types.
116    #[must_use]
117    pub const fn new_with_namespace(
118        namespace: Vec<KindString>,
119        kind: KindString,
120        id: NonZeroUsize,
121    ) -> Self {
122        Self {
123            namespace,
124            kind,
125            id,
126        }
127    }
128
129    /// Returns the namespace segments as strings.
130    pub fn namespace(&self) -> Vec<&str> {
131        self.namespace.iter().map(KindString::as_str).collect()
132    }
133
134    /// Returns the kind component as a string.
135    #[must_use]
136    pub fn kind(&self) -> &str {
137        self.kind.as_str()
138    }
139
140    /// Returns the numeric ID component.
141    #[must_use]
142    pub const fn id(&self) -> NonZeroUsize {
143        self.id
144    }
145
146    /// Returns the prefix (namespace + kind) without the numeric ID.
147    ///
148    /// For example:
149    /// - "USR" for a requirement with no namespace and kind "USR"
150    /// - "AUTH-USR" for a requirement with namespace `["AUTH"]` and kind "USR"
151    #[must_use]
152    pub fn prefix(&self) -> String {
153        if self.namespace.is_empty() {
154            self.kind.to_string()
155        } else {
156            let namespace_str = self
157                .namespace
158                .iter()
159                .map(KindString::as_str)
160                .collect::<Vec<_>>()
161                .join("-");
162            format!("{}-{}", namespace_str, self.kind)
163        }
164    }
165}
166
167impl fmt::Display for Hrid {
168    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
169        let id_str = format!("{:03}", self.id);
170        if self.namespace.is_empty() {
171            write!(f, "{}-{}", self.kind, id_str)
172        } else {
173            let namespace_str = self
174                .namespace
175                .iter()
176                .map(KindString::as_str)
177                .collect::<Vec<_>>()
178                .join("-");
179            write!(f, "{}-{}-{}", namespace_str, self.kind, id_str)
180        }
181    }
182}
183
184/// Errors that can occur during HRID parsing or construction.
185#[derive(Debug, thiserror::Error, PartialEq, Eq)]
186pub enum Error {
187    /// Invalid HRID format (malformed structure).
188    #[error("Invalid HRID format: {0}")]
189    Syntax(String),
190
191    /// Invalid ID value in HRID (non-numeric or zero).
192    #[error("Invalid ID in HRID '{0}': expected a non-zero integer, got {1}")]
193    Id(String, String),
194
195    /// ID cannot be zero.
196    #[error("Invalid ID: cannot be zero")]
197    ZeroId,
198
199    /// Invalid kind string (not uppercase alphabetic).
200    #[error(transparent)]
201    Kind(InvalidKindError),
202}
203
204impl From<InvalidKindError> for Error {
205    fn from(err: InvalidKindError) -> Self {
206        Self::Kind(err)
207    }
208}
209
210impl FromStr for Hrid {
211    type Err = Error;
212
213    fn from_str(s: &str) -> Result<Self, Self::Err> {
214        // Early validation: check for empty string or malformed structure
215        if s.is_empty()
216            || s.starts_with('-')
217            || s.ends_with('-')
218            || s.contains("--")
219            || !s.contains('-')
220        {
221            return Err(Error::Syntax(s.to_string()));
222        }
223
224        let parts: Vec<&str> = s.split('-').collect();
225
226        // Must have at least KIND-ID (2 parts)
227        if parts.len() < 2 {
228            return Err(Error::Syntax(s.to_string()));
229        }
230
231        // Parse ID from the last part
232        let id_str = parts[parts.len() - 1];
233        let id_usize = id_str
234            .parse::<usize>()
235            .map_err(|_| Error::Id(s.to_string(), id_str.to_string()))?;
236        let id = NonZeroUsize::new(id_usize)
237            .ok_or_else(|| Error::Id(s.to_string(), id_str.to_string()))?;
238
239        // Parse KIND from the second-to-last part
240        let kind_str = parts[parts.len() - 2];
241        let kind = KindString::new(kind_str.to_string())?;
242
243        // Parse namespace from all remaining parts
244        let namespace = if parts.len() > 2 {
245            parts[..parts.len() - 2]
246                .iter()
247                .map(|&segment| KindString::new(segment.to_string()))
248                .collect::<Result<Vec<_>, _>>()?
249        } else {
250            Vec::new()
251        };
252
253        Ok(Self::new_with_namespace(namespace, kind, id))
254    }
255}
256
257impl TryFrom<&str> for Hrid {
258    type Error = Error;
259
260    fn try_from(value: &str) -> Result<Self, Self::Error> {
261        Self::from_str(value)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn hrid_creation_no_namespace() {
271        let kind = KindString::new("URS".to_string()).unwrap();
272        let id = NonZeroUsize::new(42).unwrap();
273        let hrid = Hrid::new(kind, id);
274        assert!(hrid.namespace().is_empty());
275        assert_eq!(hrid.kind(), "URS");
276        assert_eq!(hrid.id().get(), 42);
277    }
278
279    #[test]
280    fn hrid_creation_with_namespace() {
281        let namespace = vec![
282            KindString::new("COMPONENT".to_string()).unwrap(),
283            KindString::new("SUBCOMPONENT".to_string()).unwrap(),
284        ];
285        let kind = KindString::new("SYS".to_string()).unwrap();
286        let id = NonZeroUsize::new(5).unwrap();
287        let hrid = Hrid::new_with_namespace(namespace, kind, id);
288
289        assert_eq!(hrid.namespace(), vec!["COMPONENT", "SUBCOMPONENT"]);
290        assert_eq!(hrid.kind(), "SYS");
291        assert_eq!(hrid.id().get(), 5);
292    }
293
294    #[test]
295    fn hrid_creation_empty_kind_fails() {
296        assert!(KindString::new(String::new()).is_err());
297    }
298
299    #[test]
300    fn hrid_creation_lowercase_kind_fails() {
301        assert!(KindString::new("sys".to_string()).is_err());
302    }
303
304    #[test]
305    fn hrid_creation_zero_id_fails() {
306        assert!(NonZeroUsize::new(0).is_none());
307    }
308
309    #[test]
310    fn hrid_display_no_namespace() {
311        let hrid = Hrid::new(
312            KindString::new("SYS".to_string()).unwrap(),
313            NonZeroUsize::new(1).unwrap(),
314        );
315        assert_eq!(format!("{hrid}"), "SYS-001");
316
317        let hrid = Hrid::new(
318            KindString::new("URS".to_string()).unwrap(),
319            NonZeroUsize::new(42).unwrap(),
320        );
321        assert_eq!(format!("{hrid}"), "URS-042");
322
323        let hrid = Hrid::new(
324            KindString::new("TEST".to_string()).unwrap(),
325            NonZeroUsize::new(999).unwrap(),
326        );
327        assert_eq!(format!("{hrid}"), "TEST-999");
328    }
329
330    #[test]
331    fn hrid_display_with_namespace() {
332        let hrid = Hrid::new_with_namespace(
333            vec![KindString::new("COMPONENT".to_string()).unwrap()],
334            KindString::new("SYS".to_string()).unwrap(),
335            NonZeroUsize::new(5).unwrap(),
336        );
337        assert_eq!(format!("{hrid}"), "COMPONENT-SYS-005");
338
339        let hrid = Hrid::new_with_namespace(
340            vec![
341                KindString::new("COMPONENT".to_string()).unwrap(),
342                KindString::new("SUBCOMPONENT".to_string()).unwrap(),
343            ],
344            KindString::new("SYS".to_string()).unwrap(),
345            NonZeroUsize::new(5).unwrap(),
346        );
347        assert_eq!(format!("{hrid}"), "COMPONENT-SUBCOMPONENT-SYS-005");
348
349        let hrid = Hrid::new_with_namespace(
350            vec![
351                KindString::new("A".to_string()).unwrap(),
352                KindString::new("B".to_string()).unwrap(),
353                KindString::new("C".to_string()).unwrap(),
354            ],
355            KindString::new("REQ".to_string()).unwrap(),
356            NonZeroUsize::new(123).unwrap(),
357        );
358        assert_eq!(format!("{hrid}"), "A-B-C-REQ-123");
359    }
360
361    #[test]
362    fn hrid_display_large_numbers() {
363        let hrid = Hrid::new(
364            KindString::new("BIG".to_string()).unwrap(),
365            NonZeroUsize::new(1000).unwrap(),
366        );
367        assert_eq!(format!("{hrid}"), "BIG-1000");
368
369        let hrid = Hrid::new_with_namespace(
370            vec![KindString::new("NS".to_string()).unwrap()],
371            KindString::new("HUGE".to_string()).unwrap(),
372            NonZeroUsize::new(12345).unwrap(),
373        );
374        assert_eq!(format!("{hrid}"), "NS-HUGE-12345");
375    }
376
377    #[test]
378    fn try_from_valid_no_namespace() {
379        let hrid = Hrid::try_from("URS-001").unwrap();
380        assert!(hrid.namespace().is_empty());
381        assert_eq!(hrid.kind(), "URS");
382        assert_eq!(hrid.id().get(), 1);
383
384        let hrid = Hrid::try_from("SYS-042").unwrap();
385        assert!(hrid.namespace().is_empty());
386        assert_eq!(hrid.kind(), "SYS");
387        assert_eq!(hrid.id().get(), 42);
388
389        let hrid = Hrid::try_from("TEST-999").unwrap();
390        assert!(hrid.namespace().is_empty());
391        assert_eq!(hrid.kind(), "TEST");
392        assert_eq!(hrid.id().get(), 999);
393    }
394
395    #[test]
396    fn try_from_valid_with_namespace() {
397        let hrid = Hrid::try_from("COMPONENT-SYS-005").unwrap();
398        assert_eq!(hrid.namespace(), vec!["COMPONENT"]);
399        assert_eq!(hrid.kind(), "SYS");
400        assert_eq!(hrid.id().get(), 5);
401
402        let hrid = Hrid::try_from("COMPONENT-SUBCOMPONENT-SYS-005").unwrap();
403        assert_eq!(hrid.namespace(), vec!["COMPONENT", "SUBCOMPONENT"]);
404        assert_eq!(hrid.kind(), "SYS");
405        assert_eq!(hrid.id().get(), 5);
406
407        let hrid = Hrid::try_from("A-B-C-REQ-123").unwrap();
408        assert_eq!(hrid.namespace(), vec!["A", "B", "C"]);
409        assert_eq!(hrid.kind(), "REQ");
410        assert_eq!(hrid.id().get(), 123);
411    }
412
413    #[test]
414    fn try_from_valid_no_leading_zeros() {
415        let hrid = Hrid::try_from("URS-1").unwrap();
416        assert!(hrid.namespace().is_empty());
417        assert_eq!(hrid.kind(), "URS");
418        assert_eq!(hrid.id().get(), 1);
419
420        let hrid = Hrid::try_from("NS-SYS-42").unwrap();
421        assert_eq!(hrid.namespace(), vec!["NS"]);
422        assert_eq!(hrid.kind(), "SYS");
423        assert_eq!(hrid.id().get(), 42);
424    }
425
426    #[test]
427    fn try_from_valid_large_numbers() {
428        let hrid = Hrid::try_from("BIG-1000").unwrap();
429        assert!(hrid.namespace().is_empty());
430        assert_eq!(hrid.kind(), "BIG");
431        assert_eq!(hrid.id().get(), 1000);
432
433        let hrid = Hrid::try_from("NS-HUGE-12345").unwrap();
434        assert_eq!(hrid.namespace(), vec!["NS"]);
435        assert_eq!(hrid.kind(), "HUGE");
436        assert_eq!(hrid.id().get(), 12345);
437    }
438
439    #[test]
440    fn try_from_invalid_no_dash() {
441        let result = Hrid::try_from("URS001");
442        assert!(matches!(result, Err(Error::Syntax(_))));
443    }
444
445    #[test]
446    fn try_from_invalid_empty_string() {
447        let result = Hrid::try_from("");
448        assert!(matches!(result, Err(Error::Syntax(_))));
449    }
450
451    #[test]
452    fn try_from_invalid_only_dash() {
453        let result = Hrid::try_from("-");
454        assert!(matches!(result, Err(Error::Syntax(_))));
455    }
456
457    #[test]
458    fn try_from_invalid_single_part() {
459        let result = Hrid::try_from("JUSTONEWORD");
460        assert!(matches!(result, Err(Error::Syntax(_))));
461    }
462
463    #[test]
464    fn try_from_invalid_non_numeric_id() {
465        let result = Hrid::try_from("URS-abc");
466        assert!(matches!(result, Err(Error::Id(_, _))));
467
468        let result = Hrid::try_from("NS-URS-abc");
469        assert!(matches!(result, Err(Error::Id(_, _))));
470    }
471
472    #[test]
473    fn try_from_invalid_mixed_id() {
474        let result = Hrid::try_from("SYS-12abc");
475        assert!(matches!(result, Err(Error::Id(_, _))));
476    }
477
478    #[test]
479    fn try_from_invalid_negative_id() {
480        let result = Hrid::try_from("URS--1");
481        assert!(matches!(result, Err(Error::Syntax(_))));
482    }
483
484    #[test]
485    fn try_from_invalid_zero_id() {
486        let result = Hrid::try_from("URS-0");
487        assert!(matches!(result, Err(Error::Id(_, _))));
488    }
489
490    #[test]
491    fn try_from_invalid_lowercase_kind() {
492        let result = Hrid::try_from("urs-001");
493        assert!(matches!(result, Err(Error::Kind(_))));
494    }
495
496    #[test]
497    fn try_from_invalid_lowercase_namespace() {
498        let result = Hrid::try_from("ns-URS-001");
499        assert!(matches!(result, Err(Error::Kind(_))));
500    }
501
502    #[test]
503    fn try_from_empty_namespace_segment_fails() {
504        let result = Hrid::try_from("-NS-SYS-001");
505        assert!(matches!(result, Err(Error::Syntax(_))));
506
507        let result = Hrid::try_from("NS--SYS-001");
508        assert!(matches!(result, Err(Error::Syntax(_))));
509    }
510
511    #[test]
512    fn try_from_empty_kind_fails() {
513        let result = Hrid::try_from("-001");
514        assert!(matches!(result, Err(Error::Syntax(_))));
515    }
516
517    #[test]
518    fn hrid_clone_and_eq() {
519        let hrid1 = Hrid::new_with_namespace(
520            vec![KindString::new("NS".to_string()).unwrap()],
521            KindString::new("URS".to_string()).unwrap(),
522            NonZeroUsize::new(42).unwrap(),
523        );
524        let hrid2 = hrid1.clone();
525
526        assert_eq!(hrid1, hrid2);
527        assert_eq!(hrid1.namespace(), hrid2.namespace());
528        assert_eq!(hrid1.kind(), hrid2.kind());
529        assert_eq!(hrid1.id(), hrid2.id());
530    }
531
532    #[test]
533    fn hrid_not_eq() {
534        let hrid1 = Hrid::new(
535            KindString::new("URS".to_string()).unwrap(),
536            NonZeroUsize::new(42).unwrap(),
537        );
538        let hrid2 = Hrid::new(
539            KindString::new("SYS".to_string()).unwrap(),
540            NonZeroUsize::new(42).unwrap(),
541        );
542        let hrid3 = Hrid::new(
543            KindString::new("URS".to_string()).unwrap(),
544            NonZeroUsize::new(43).unwrap(),
545        );
546        let hrid4 = Hrid::new_with_namespace(
547            vec![KindString::new("NS".to_string()).unwrap()],
548            KindString::new("URS".to_string()).unwrap(),
549            NonZeroUsize::new(42).unwrap(),
550        );
551
552        assert_ne!(hrid1, hrid2);
553        assert_ne!(hrid1, hrid3);
554        assert_ne!(hrid1, hrid4);
555    }
556
557    #[test]
558    fn roundtrip_conversion_no_namespace() {
559        let original = Hrid::new(
560            KindString::new("TEST".to_string()).unwrap(),
561            NonZeroUsize::new(123).unwrap(),
562        );
563
564        let as_string = format!("{original}");
565        let parsed = Hrid::try_from(as_string.as_str()).unwrap();
566
567        assert_eq!(original, parsed);
568    }
569
570    #[test]
571    fn roundtrip_conversion_with_namespace() {
572        let original = Hrid::new_with_namespace(
573            vec![
574                KindString::new("COMPONENT".to_string()).unwrap(),
575                KindString::new("SUBCOMPONENT".to_string()).unwrap(),
576            ],
577            KindString::new("SYS".to_string()).unwrap(),
578            NonZeroUsize::new(5).unwrap(),
579        );
580
581        let as_string = format!("{original}");
582        let parsed = Hrid::try_from(as_string.as_str()).unwrap();
583
584        assert_eq!(original, parsed);
585    }
586
587    #[test]
588    fn strict_uppercase_validation() {
589        // Domain layer is strict - lowercase should fail
590        assert!(KindString::new("sys".to_string()).is_err());
591
592        // FromStr is also strict
593        let result = Hrid::from_str("component-sys-001");
594        assert!(matches!(result, Err(Error::Kind(_))));
595    }
596
597    #[test]
598    fn error_display() {
599        let syntax_error = Error::Syntax("bad-format".to_string());
600        assert_eq!(format!("{syntax_error}"), "Invalid HRID format: bad-format");
601
602        let id_error = Error::Id("URS-bad".to_string(), "bad".to_string());
603        assert_eq!(
604            format!("{id_error}"),
605            "Invalid ID in HRID 'URS-bad': expected a non-zero integer, got bad"
606        );
607    }
608}