use std::{fmt, num::NonZeroUsize, ops::Deref, str::FromStr};
use non_empty_string::NonEmptyString;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct KindString(NonEmptyString);
impl KindString {
pub fn new(s: String) -> Result<Self, InvalidKindError> {
let non_empty = NonEmptyString::new(s.clone()).map_err(|_| InvalidKindError(s.clone()))?;
if !s.chars().all(|c| c.is_ascii_uppercase()) {
return Err(InvalidKindError(s));
}
Ok(Self(non_empty))
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl TryFrom<String> for KindString {
type Error = InvalidKindError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for KindString {
type Error = InvalidKindError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value.to_string())
}
}
impl AsRef<str> for KindString {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}
impl Deref for KindString {
type Target = str;
fn deref(&self) -> &Self::Target {
self.0.as_str()
}
}
impl fmt::Display for KindString {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for KindString {
type Err = InvalidKindError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s.to_string())
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[error("Invalid kind string '{0}': must be non-empty and contain only uppercase letters (A-Z)")]
pub struct InvalidKindError(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Hrid {
namespace: Vec<KindString>,
kind: KindString,
id: NonZeroUsize,
}
impl Hrid {
#[must_use]
pub const fn new(kind: KindString, id: NonZeroUsize) -> Self {
Self::new_with_namespace(Vec::new(), kind, id)
}
#[must_use]
pub const fn new_with_namespace(
namespace: Vec<KindString>,
kind: KindString,
id: NonZeroUsize,
) -> Self {
Self {
namespace,
kind,
id,
}
}
pub fn namespace(&self) -> Vec<&str> {
self.namespace.iter().map(KindString::as_str).collect()
}
#[must_use]
pub fn kind(&self) -> &str {
self.kind.as_str()
}
#[must_use]
pub const fn id(&self) -> NonZeroUsize {
self.id
}
#[must_use]
pub fn prefix(&self) -> String {
if self.namespace.is_empty() {
self.kind.to_string()
} else {
let namespace_str = self
.namespace
.iter()
.map(KindString::as_str)
.collect::<Vec<_>>()
.join("-");
format!("{}-{}", namespace_str, self.kind)
}
}
}
impl fmt::Display for Hrid {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let id_str = format!("{:03}", self.id);
if self.namespace.is_empty() {
write!(f, "{}-{}", self.kind, id_str)
} else {
let namespace_str = self
.namespace
.iter()
.map(KindString::as_str)
.collect::<Vec<_>>()
.join("-");
write!(f, "{}-{}-{}", namespace_str, self.kind, id_str)
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
#[error("Invalid HRID format: {0}")]
Syntax(String),
#[error("Invalid ID in HRID '{0}': expected a non-zero integer, got {1}")]
Id(String, String),
#[error("Invalid ID: cannot be zero")]
ZeroId,
#[error(transparent)]
Kind(InvalidKindError),
}
impl From<InvalidKindError> for Error {
fn from(err: InvalidKindError) -> Self {
Self::Kind(err)
}
}
impl FromStr for Hrid {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty()
|| s.starts_with('-')
|| s.ends_with('-')
|| s.contains("--")
|| !s.contains('-')
{
return Err(Error::Syntax(s.to_string()));
}
let parts: Vec<&str> = s.split('-').collect();
if parts.len() < 2 {
return Err(Error::Syntax(s.to_string()));
}
let id_str = parts[parts.len() - 1];
let id_usize = id_str
.parse::<usize>()
.map_err(|_| Error::Id(s.to_string(), id_str.to_string()))?;
let id = NonZeroUsize::new(id_usize)
.ok_or_else(|| Error::Id(s.to_string(), id_str.to_string()))?;
let kind_str = parts[parts.len() - 2];
let kind = KindString::new(kind_str.to_string())?;
let namespace = if parts.len() > 2 {
parts[..parts.len() - 2]
.iter()
.map(|&segment| KindString::new(segment.to_string()))
.collect::<Result<Vec<_>, _>>()?
} else {
Vec::new()
};
Ok(Self::new_with_namespace(namespace, kind, id))
}
}
impl TryFrom<&str> for Hrid {
type Error = Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hrid_creation_no_namespace() {
let kind = KindString::new("URS".to_string()).unwrap();
let id = NonZeroUsize::new(42).unwrap();
let hrid = Hrid::new(kind, id);
assert!(hrid.namespace().is_empty());
assert_eq!(hrid.kind(), "URS");
assert_eq!(hrid.id().get(), 42);
}
#[test]
fn hrid_creation_with_namespace() {
let namespace = vec![
KindString::new("COMPONENT".to_string()).unwrap(),
KindString::new("SUBCOMPONENT".to_string()).unwrap(),
];
let kind = KindString::new("SYS".to_string()).unwrap();
let id = NonZeroUsize::new(5).unwrap();
let hrid = Hrid::new_with_namespace(namespace, kind, id);
assert_eq!(hrid.namespace(), vec!["COMPONENT", "SUBCOMPONENT"]);
assert_eq!(hrid.kind(), "SYS");
assert_eq!(hrid.id().get(), 5);
}
#[test]
fn hrid_creation_empty_kind_fails() {
assert!(KindString::new(String::new()).is_err());
}
#[test]
fn hrid_creation_lowercase_kind_fails() {
assert!(KindString::new("sys".to_string()).is_err());
}
#[test]
fn hrid_creation_zero_id_fails() {
assert!(NonZeroUsize::new(0).is_none());
}
#[test]
fn hrid_display_no_namespace() {
let hrid = Hrid::new(
KindString::new("SYS".to_string()).unwrap(),
NonZeroUsize::new(1).unwrap(),
);
assert_eq!(format!("{hrid}"), "SYS-001");
let hrid = Hrid::new(
KindString::new("URS".to_string()).unwrap(),
NonZeroUsize::new(42).unwrap(),
);
assert_eq!(format!("{hrid}"), "URS-042");
let hrid = Hrid::new(
KindString::new("TEST".to_string()).unwrap(),
NonZeroUsize::new(999).unwrap(),
);
assert_eq!(format!("{hrid}"), "TEST-999");
}
#[test]
fn hrid_display_with_namespace() {
let hrid = Hrid::new_with_namespace(
vec![KindString::new("COMPONENT".to_string()).unwrap()],
KindString::new("SYS".to_string()).unwrap(),
NonZeroUsize::new(5).unwrap(),
);
assert_eq!(format!("{hrid}"), "COMPONENT-SYS-005");
let hrid = Hrid::new_with_namespace(
vec![
KindString::new("COMPONENT".to_string()).unwrap(),
KindString::new("SUBCOMPONENT".to_string()).unwrap(),
],
KindString::new("SYS".to_string()).unwrap(),
NonZeroUsize::new(5).unwrap(),
);
assert_eq!(format!("{hrid}"), "COMPONENT-SUBCOMPONENT-SYS-005");
let hrid = Hrid::new_with_namespace(
vec![
KindString::new("A".to_string()).unwrap(),
KindString::new("B".to_string()).unwrap(),
KindString::new("C".to_string()).unwrap(),
],
KindString::new("REQ".to_string()).unwrap(),
NonZeroUsize::new(123).unwrap(),
);
assert_eq!(format!("{hrid}"), "A-B-C-REQ-123");
}
#[test]
fn hrid_display_large_numbers() {
let hrid = Hrid::new(
KindString::new("BIG".to_string()).unwrap(),
NonZeroUsize::new(1000).unwrap(),
);
assert_eq!(format!("{hrid}"), "BIG-1000");
let hrid = Hrid::new_with_namespace(
vec![KindString::new("NS".to_string()).unwrap()],
KindString::new("HUGE".to_string()).unwrap(),
NonZeroUsize::new(12345).unwrap(),
);
assert_eq!(format!("{hrid}"), "NS-HUGE-12345");
}
#[test]
fn try_from_valid_no_namespace() {
let hrid = Hrid::try_from("URS-001").unwrap();
assert!(hrid.namespace().is_empty());
assert_eq!(hrid.kind(), "URS");
assert_eq!(hrid.id().get(), 1);
let hrid = Hrid::try_from("SYS-042").unwrap();
assert!(hrid.namespace().is_empty());
assert_eq!(hrid.kind(), "SYS");
assert_eq!(hrid.id().get(), 42);
let hrid = Hrid::try_from("TEST-999").unwrap();
assert!(hrid.namespace().is_empty());
assert_eq!(hrid.kind(), "TEST");
assert_eq!(hrid.id().get(), 999);
}
#[test]
fn try_from_valid_with_namespace() {
let hrid = Hrid::try_from("COMPONENT-SYS-005").unwrap();
assert_eq!(hrid.namespace(), vec!["COMPONENT"]);
assert_eq!(hrid.kind(), "SYS");
assert_eq!(hrid.id().get(), 5);
let hrid = Hrid::try_from("COMPONENT-SUBCOMPONENT-SYS-005").unwrap();
assert_eq!(hrid.namespace(), vec!["COMPONENT", "SUBCOMPONENT"]);
assert_eq!(hrid.kind(), "SYS");
assert_eq!(hrid.id().get(), 5);
let hrid = Hrid::try_from("A-B-C-REQ-123").unwrap();
assert_eq!(hrid.namespace(), vec!["A", "B", "C"]);
assert_eq!(hrid.kind(), "REQ");
assert_eq!(hrid.id().get(), 123);
}
#[test]
fn try_from_valid_no_leading_zeros() {
let hrid = Hrid::try_from("URS-1").unwrap();
assert!(hrid.namespace().is_empty());
assert_eq!(hrid.kind(), "URS");
assert_eq!(hrid.id().get(), 1);
let hrid = Hrid::try_from("NS-SYS-42").unwrap();
assert_eq!(hrid.namespace(), vec!["NS"]);
assert_eq!(hrid.kind(), "SYS");
assert_eq!(hrid.id().get(), 42);
}
#[test]
fn try_from_valid_large_numbers() {
let hrid = Hrid::try_from("BIG-1000").unwrap();
assert!(hrid.namespace().is_empty());
assert_eq!(hrid.kind(), "BIG");
assert_eq!(hrid.id().get(), 1000);
let hrid = Hrid::try_from("NS-HUGE-12345").unwrap();
assert_eq!(hrid.namespace(), vec!["NS"]);
assert_eq!(hrid.kind(), "HUGE");
assert_eq!(hrid.id().get(), 12345);
}
#[test]
fn try_from_invalid_no_dash() {
let result = Hrid::try_from("URS001");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn try_from_invalid_empty_string() {
let result = Hrid::try_from("");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn try_from_invalid_only_dash() {
let result = Hrid::try_from("-");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn try_from_invalid_single_part() {
let result = Hrid::try_from("JUSTONEWORD");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn try_from_invalid_non_numeric_id() {
let result = Hrid::try_from("URS-abc");
assert!(matches!(result, Err(Error::Id(_, _))));
let result = Hrid::try_from("NS-URS-abc");
assert!(matches!(result, Err(Error::Id(_, _))));
}
#[test]
fn try_from_invalid_mixed_id() {
let result = Hrid::try_from("SYS-12abc");
assert!(matches!(result, Err(Error::Id(_, _))));
}
#[test]
fn try_from_invalid_negative_id() {
let result = Hrid::try_from("URS--1");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn try_from_invalid_zero_id() {
let result = Hrid::try_from("URS-0");
assert!(matches!(result, Err(Error::Id(_, _))));
}
#[test]
fn try_from_invalid_lowercase_kind() {
let result = Hrid::try_from("urs-001");
assert!(matches!(result, Err(Error::Kind(_))));
}
#[test]
fn try_from_invalid_lowercase_namespace() {
let result = Hrid::try_from("ns-URS-001");
assert!(matches!(result, Err(Error::Kind(_))));
}
#[test]
fn try_from_empty_namespace_segment_fails() {
let result = Hrid::try_from("-NS-SYS-001");
assert!(matches!(result, Err(Error::Syntax(_))));
let result = Hrid::try_from("NS--SYS-001");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn try_from_empty_kind_fails() {
let result = Hrid::try_from("-001");
assert!(matches!(result, Err(Error::Syntax(_))));
}
#[test]
fn hrid_clone_and_eq() {
let hrid1 = Hrid::new_with_namespace(
vec![KindString::new("NS".to_string()).unwrap()],
KindString::new("URS".to_string()).unwrap(),
NonZeroUsize::new(42).unwrap(),
);
let hrid2 = hrid1.clone();
assert_eq!(hrid1, hrid2);
assert_eq!(hrid1.namespace(), hrid2.namespace());
assert_eq!(hrid1.kind(), hrid2.kind());
assert_eq!(hrid1.id(), hrid2.id());
}
#[test]
fn hrid_not_eq() {
let hrid1 = Hrid::new(
KindString::new("URS".to_string()).unwrap(),
NonZeroUsize::new(42).unwrap(),
);
let hrid2 = Hrid::new(
KindString::new("SYS".to_string()).unwrap(),
NonZeroUsize::new(42).unwrap(),
);
let hrid3 = Hrid::new(
KindString::new("URS".to_string()).unwrap(),
NonZeroUsize::new(43).unwrap(),
);
let hrid4 = Hrid::new_with_namespace(
vec![KindString::new("NS".to_string()).unwrap()],
KindString::new("URS".to_string()).unwrap(),
NonZeroUsize::new(42).unwrap(),
);
assert_ne!(hrid1, hrid2);
assert_ne!(hrid1, hrid3);
assert_ne!(hrid1, hrid4);
}
#[test]
fn roundtrip_conversion_no_namespace() {
let original = Hrid::new(
KindString::new("TEST".to_string()).unwrap(),
NonZeroUsize::new(123).unwrap(),
);
let as_string = format!("{original}");
let parsed = Hrid::try_from(as_string.as_str()).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn roundtrip_conversion_with_namespace() {
let original = Hrid::new_with_namespace(
vec![
KindString::new("COMPONENT".to_string()).unwrap(),
KindString::new("SUBCOMPONENT".to_string()).unwrap(),
],
KindString::new("SYS".to_string()).unwrap(),
NonZeroUsize::new(5).unwrap(),
);
let as_string = format!("{original}");
let parsed = Hrid::try_from(as_string.as_str()).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn strict_uppercase_validation() {
assert!(KindString::new("sys".to_string()).is_err());
let result = Hrid::from_str("component-sys-001");
assert!(matches!(result, Err(Error::Kind(_))));
}
#[test]
fn error_display() {
let syntax_error = Error::Syntax("bad-format".to_string());
assert_eq!(format!("{syntax_error}"), "Invalid HRID format: bad-format");
let id_error = Error::Id("URS-bad".to_string(), "bad".to_string());
assert_eq!(
format!("{id_error}"),
"Invalid ID in HRID 'URS-bad': expected a non-zero integer, got bad"
);
}
}