use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EntityRef(String);
impl serde::Serialize for EntityRef {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl EntityRef {
pub fn new(s: impl Into<String>) -> anyhow::Result<Self> {
Self::parse_any(s.into().as_str())
}
pub fn parse_v3(s: &str) -> anyhow::Result<Self> {
parse_with(s, validate_v3_suffix)
}
pub fn parse_v4(s: &str) -> anyhow::Result<Self> {
parse_with(s, validate_v4_suffix)
}
pub fn parse_v5(s: &str) -> anyhow::Result<Self> {
parse_with(s, validate_v5_suffix)
}
pub fn parse_any(s: &str) -> anyhow::Result<Self> {
parse_with(s, |full, suffix| {
validate_v5_suffix(full, suffix)
.or_else(|_| validate_v4_suffix(full, suffix))
.or_else(|_| validate_v3_suffix(full, suffix))
})
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn prefix(&self) -> &str {
let pos = self.0.rfind('-').unwrap();
&self.0[..pos]
}
pub fn suffix(&self) -> &str {
let pos = self.0.rfind('-').unwrap();
&self.0[pos + 1..]
}
pub fn numeric_id(&self) -> u64 {
let suffix = self.suffix();
if suffix.len() == 26 {
if let Some(v) = decode_crockford_to_u128(suffix) {
return v as u64;
}
}
if suffix.len() == 13 {
if let Some(v) = decode_crockford_to_u64(suffix) {
return v;
}
}
suffix.parse().unwrap()
}
}
fn parse_with(
s: &str,
suffix_validator: impl FnOnce(&str, &str) -> anyhow::Result<()>,
) -> anyhow::Result<EntityRef> {
let (prefix, suffix) = split(s)?;
validate_prefix(s, prefix)?;
suffix_validator(s, suffix)?;
Ok(EntityRef(s.to_string()))
}
fn split(s: &str) -> anyhow::Result<(&str, &str)> {
let pos = s
.rfind('-')
.ok_or_else(|| anyhow::anyhow!("invalid entity ref '{s}': missing '-'"))?;
Ok((&s[..pos], &s[pos + 1..]))
}
fn validate_prefix(full: &str, prefix: &str) -> anyhow::Result<()> {
if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
anyhow::bail!(
"invalid entity ref '{full}': prefix must be one or more uppercase ASCII letters"
);
}
Ok(())
}
fn validate_v3_suffix(full: &str, suffix: &str) -> anyhow::Result<()> {
let n: u32 = suffix.parse().map_err(|_| {
anyhow::anyhow!("invalid entity ref '{full}': v3 suffix must be a non-zero integer")
})?;
if n == 0 {
anyhow::bail!("invalid entity ref '{full}': numeric id cannot be zero");
}
Ok(())
}
fn validate_v4_suffix(full: &str, suffix: &str) -> anyhow::Result<()> {
if suffix.len() != 13 {
anyhow::bail!("invalid entity ref '{full}': v4 suffix must be exactly 13 characters");
}
decode_crockford_to_u64(suffix).ok_or_else(|| {
anyhow::anyhow!(
"invalid entity ref '{full}': v4 suffix must be a valid Crockford base32 TSID"
)
})?;
Ok(())
}
fn validate_v5_suffix(full: &str, suffix: &str) -> anyhow::Result<()> {
if suffix.len() != 26 {
anyhow::bail!("invalid entity ref '{full}': v5 suffix must be exactly 26 characters");
}
decode_crockford_to_u128(suffix).ok_or_else(|| {
anyhow::anyhow!(
"invalid entity ref '{full}': v5 suffix must be a valid Crockford base32 ULID"
)
})?;
Ok(())
}
fn decode_crockford_to_u64(s: &str) -> Option<u64> {
if s.len() != 13 {
return None;
}
let mut value: u64 = 0;
for (position, ch) in s.chars().enumerate() {
let bits = decode_crockford_char(ch)?;
if position == 0 && bits >= 16 {
return None;
}
value = (value << 5) | bits as u64;
}
Some(value)
}
fn decode_crockford_to_u128(s: &str) -> Option<u128> {
if s.len() != 26 {
return None;
}
let mut value: u128 = 0;
for (position, ch) in s.chars().enumerate() {
let bits = decode_crockford_char(ch)?;
if position == 0 && bits >= 8 {
return None;
}
value = (value << 5) | bits as u128;
}
Some(value)
}
fn decode_crockford_char(ch: char) -> Option<u8> {
let upper = ch.to_ascii_uppercase();
match upper {
'0' | 'O' => Some(0),
'1' | 'I' | 'L' => Some(1),
'2'..='9' => Some(upper as u8 - b'0'),
'A'..='H' => Some(upper as u8 - b'A' + 10),
'J' => Some(18),
'K' => Some(19),
'M' => Some(20),
'N' => Some(21),
'P'..='T' => Some(upper as u8 - b'P' + 22),
'V'..='Z' => Some(upper as u8 - b'V' + 27),
_ => None,
}
}
impl fmt::Display for EntityRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for EntityRef {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
EntityRef::new(s)
}
}
#[derive(Debug, Default, Clone)]
pub struct KnownRefs(std::collections::HashSet<EntityRef>);
impl KnownRefs {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, r: EntityRef) {
self.0.insert(r);
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn contains(&self, r: &EntityRef) -> bool {
self.0.contains(r)
}
}
impl FromIterator<EntityRef> for KnownRefs {
fn from_iter<I: IntoIterator<Item = EntityRef>>(iter: I) -> Self {
KnownRefs(iter.into_iter().collect())
}
}
#[cfg(test)]
pub mod strategy {
use super::*;
use proptest::prelude::*;
pub fn entity_ref() -> impl Strategy<Value = EntityRef> {
(
proptest::sample::select(vec!["ISSUE", "ADR", "DDR", "GDDR"]),
1u32..10_000,
)
.prop_map(|(prefix, n)| EntityRef(format!("{prefix}-{n:04}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn new_accepts_issue_ref() {
let r = EntityRef::new("ISSUE-0006").unwrap();
assert_eq!(r.as_str(), "ISSUE-0006");
}
#[test]
fn new_accepts_adr_ref() {
let r = EntityRef::new("ADR-0001").unwrap();
assert_eq!(r.as_str(), "ADR-0001");
}
#[test]
fn new_accepts_multi_letter_prefix() {
let r = EntityRef::new("GDDR-0042").unwrap();
assert_eq!(r.prefix(), "GDDR");
assert_eq!(r.numeric_id(), 42);
}
#[test]
fn new_rejects_missing_dash() {
assert!(EntityRef::new("ISSUE0006").is_err());
}
#[test]
fn new_rejects_lowercase_prefix() {
assert!(EntityRef::new("adr-0001").is_err());
}
#[test]
fn new_rejects_mixed_case_prefix() {
assert!(EntityRef::new("Adr-0001").is_err());
}
#[test]
fn new_rejects_zero_id() {
assert!(EntityRef::new("ADR-0000").is_err());
}
#[test]
fn new_rejects_non_numeric_suffix() {
assert!(EntityRef::new("ADR-abc").is_err());
}
#[test]
fn new_rejects_empty_prefix() {
assert!(EntityRef::new("-0001").is_err());
}
#[test]
fn prefix_extracts_correctly() {
assert_eq!(EntityRef::new("ISSUE-0006").unwrap().prefix(), "ISSUE");
assert_eq!(EntityRef::new("ADR-0001").unwrap().prefix(), "ADR");
}
#[test]
fn numeric_id_extracts_correctly() {
assert_eq!(EntityRef::new("ISSUE-0006").unwrap().numeric_id(), 6);
assert_eq!(EntityRef::new("ADR-0042").unwrap().numeric_id(), 42);
}
#[test]
fn display_roundtrips() {
let r = EntityRef::new("ADR-0001").unwrap();
assert_eq!(r.to_string(), "ADR-0001");
}
#[test]
fn from_str_roundtrips() {
let r: EntityRef = "ISSUE-0051".parse().unwrap();
assert_eq!(r.as_str(), "ISSUE-0051");
}
#[test]
fn equality_holds_for_same_value() {
assert_eq!(
EntityRef::new("ADR-0001").unwrap(),
EntityRef::new("ADR-0001").unwrap()
);
assert_ne!(
EntityRef::new("ADR-0001").unwrap(),
EntityRef::new("ADR-0002").unwrap()
);
assert_ne!(
EntityRef::new("ADR-0001").unwrap(),
EntityRef::new("ISSUE-0001").unwrap()
);
}
#[test]
fn new_accepts_tsid_suffix() {
let r = EntityRef::new("ISSUE-0DCT3MKW5T2K0").unwrap();
assert_eq!(r.prefix(), "ISSUE");
assert_eq!(r.suffix(), "0DCT3MKW5T2K0");
}
#[test]
fn new_accepts_tsid_suffix_for_decision_record() {
let r = EntityRef::new("ADR-0DCT4P9X8N3R7").unwrap();
assert_eq!(r.prefix(), "ADR");
assert_eq!(r.suffix(), "0DCT4P9X8N3R7");
}
#[test]
fn new_rejects_thirteen_char_invalid_tsid() {
assert!(EntityRef::new("ISSUE-UUUUUUUUUUUUU").is_err());
}
#[test]
fn numeric_id_for_v4_suffix_returns_full_u64() {
let r = EntityRef::new("ISSUE-F000000000000").unwrap();
assert_eq!(r.numeric_id(), 0xF000_0000_0000_0000u64);
}
#[test]
fn parse_v3_accepts_legacy_numeric_suffix() {
assert!(EntityRef::parse_v3("ISSUE-0006").is_ok());
assert!(EntityRef::parse_v3("ADR-0042").is_ok());
}
#[test]
fn parse_v3_rejects_tsid_suffix() {
let err = EntityRef::parse_v3("ISSUE-0DCT3MKW5T2K0").unwrap_err();
assert!(
err.to_string().contains("v3 suffix"),
"expected v3 suffix error, got: {err}"
);
}
#[test]
fn parse_v4_accepts_tsid_suffix() {
assert!(EntityRef::parse_v4("ISSUE-0DCT3MKW5T2K0").is_ok());
}
#[test]
fn parse_v4_rejects_legacy_numeric_suffix() {
let err = EntityRef::parse_v4("ISSUE-0042").unwrap_err();
assert!(
err.to_string().contains("v4 suffix"),
"expected v4 suffix error, got: {err}"
);
}
#[test]
fn parse_any_accepts_all_three_shapes() {
assert!(EntityRef::parse_any("ISSUE-0006").is_ok());
assert!(EntityRef::parse_any("ISSUE-0DCT3MKW5T2K0").is_ok());
assert!(EntityRef::parse_any("ISSUE-01J9ZK4T5M8N3QXA7BR2HVPMD0").is_ok());
}
#[test]
fn parse_any_rejects_garbage() {
assert!(EntityRef::parse_any("ISSUE-abc").is_err());
assert!(EntityRef::parse_any("ISSUE-UUUUUUUUUUUUU").is_err());
assert!(EntityRef::parse_any("ISSUE-UUUUUUUUUUUUUUUUUUUUUUUUUU").is_err());
}
#[test]
fn parse_v5_accepts_ulid_suffix() {
let r = EntityRef::parse_v5("ISSUE-01J9ZK4T5M8N3QXA7BR2HVPMD0").unwrap();
assert_eq!(r.prefix(), "ISSUE");
assert_eq!(r.suffix(), "01J9ZK4T5M8N3QXA7BR2HVPMD0");
}
#[test]
fn parse_v5_rejects_tsid_suffix() {
let err = EntityRef::parse_v5("ISSUE-0DCT3MKW5T2K0").unwrap_err();
assert!(
err.to_string().contains("v5 suffix"),
"expected v5 suffix error, got: {err}"
);
}
#[test]
fn parse_v5_rejects_overflowing_leading_char() {
let err = EntityRef::parse_v5("ISSUE-80000000000000000000000000").unwrap_err();
assert!(
err.to_string().contains("v5 suffix"),
"expected v5 suffix error, got: {err}"
);
}
#[test]
fn numeric_id_for_v5_suffix_returns_low_u64() {
let r = EntityRef::new("ISSUE-0000000000000F000000000000").unwrap();
assert_eq!(r.numeric_id(), 0xF000_0000_0000_0000u64);
}
proptest! {
#[test]
fn prop_strategy_always_produces_valid_refs(r in strategy::entity_ref()) {
prop_assert!(EntityRef::new(r.as_str()).is_ok());
}
#[test]
fn prop_display_roundtrips(r in strategy::entity_ref()) {
let s = r.to_string();
prop_assert_eq!(s.parse::<EntityRef>().unwrap(), r);
}
#[test]
fn prop_prefix_is_uppercase(r in strategy::entity_ref()) {
prop_assert!(r.prefix().chars().all(|c| c.is_ascii_uppercase()));
}
#[test]
fn prop_numeric_id_is_positive(r in strategy::entity_ref()) {
prop_assert!(r.numeric_id() > 0);
}
}
}