use core::fmt;
use core::str::FromStr;
use ulid::Ulid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct Ppnum(String);
impl Ppnum {
pub const MIN_LEN: usize = 11;
pub const CONSTRAINT: &'static str = "ck_ppnums_format";
pub const PATTERN: &'static str = "^[0-9]{11,}$";
pub fn parse(s: &str) -> Result<Self, PpnumError> {
validate(s)?;
Ok(Self(s.to_owned()))
}
pub fn from_display(s: &str) -> Result<Self, PpnumError> {
let digits: String = s
.chars()
.filter(|c| !(*c == '-' || c.is_ascii_whitespace()))
.collect();
validate(&digits)?;
Ok(Self(digits))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
#[must_use]
pub fn display(&self) -> String {
Self::display_form(&self.0)
}
#[must_use]
pub fn display_form(s: &str) -> String {
if validate(s).is_err() {
return s.to_owned();
}
let mut out = String::with_capacity(s.len() + s.len() / 4 + 1);
out.push_str(&s[0..3]);
out.push('-');
out.push_str(&s[3..7]);
out.push('-');
out.push_str(&s[7..Self::MIN_LEN]);
let mut pos = Self::MIN_LEN;
while pos < s.len() {
let end = (pos + 4).min(s.len());
out.push('-');
out.push_str(&s[pos..end]);
pos = end;
}
out
}
}
fn validate(s: &str) -> Result<(), PpnumError> {
if s.is_empty() {
return Err(PpnumError::Empty);
}
if let Some(pos) = s.bytes().position(|b| !b.is_ascii_digit()) {
return Err(PpnumError::NonDigit { pos });
}
if s.len() < Ppnum::MIN_LEN {
return Err(PpnumError::TooShort { len: s.len() });
}
Ok(())
}
impl fmt::Display for Ppnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Ppnum {
fn as_ref(&self) -> &str {
&self.0
}
}
impl FromStr for Ppnum {
type Err = PpnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<String> for Ppnum {
type Error = PpnumError;
fn try_from(s: String) -> Result<Self, Self::Error> {
validate(&s)?;
Ok(Self(s))
}
}
impl From<Ppnum> for String {
fn from(p: Ppnum) -> Self {
p.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PpnumError {
Empty,
TooShort {
len: usize,
},
NonDigit {
pos: usize,
},
}
impl fmt::Display for PpnumError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("ppnum is empty"),
Self::TooShort { len } => write!(
f,
"ppnum too short ({len} digits, minimum {})",
Ppnum::MIN_LEN
),
Self::NonDigit { pos } => {
write!(f, "ppnum contains non-digit character at position {pos}")
}
}
}
}
impl std::error::Error for PpnumError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct PpnumId(pub Ulid);
impl PpnumId {
#[must_use]
pub fn new() -> Self {
Self(Ulid::generate())
}
#[must_use]
pub const fn nil() -> Self {
Self(Ulid::nil())
}
}
impl Default for PpnumId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for PpnumId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl FromStr for PpnumId {
type Err = ulid::DecodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ulid::from_str(s).map(Self)
}
}
impl From<Ulid> for PpnumId {
fn from(u: Ulid) -> Self {
Self(u)
}
}
impl From<PpnumId> for Ulid {
fn from(id: PpnumId) -> Self {
id.0
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn eleven_digits_is_the_floor_and_any_first_digit_is_valid() {
for first in '0'..='9' {
let s = format!("{first}0012345678");
assert_eq!(Ppnum::parse(&s).unwrap().as_str(), s, "{s} must parse");
}
assert_eq!(
Ppnum::parse("1234567890"),
Err(PpnumError::TooShort { len: 10 })
);
}
#[test]
fn longer_tiers_are_valid_ppnums() {
for s in [
"123123456780001",
"1231234567800010001",
"12312345678000100010001",
] {
assert!(Ppnum::parse(s).is_ok(), "{s} must parse");
}
assert!(Ppnum::parse("123456789012").is_ok());
}
#[test]
fn non_digits_are_named_by_position_before_length_is_judged() {
assert_eq!(
Ppnum::parse("1234567890a"),
Err(PpnumError::NonDigit { pos: 10 })
);
assert_eq!(Ppnum::parse("12a"), Err(PpnumError::NonDigit { pos: 2 }));
assert_eq!(
Ppnum::parse("123-1234-5678"),
Err(PpnumError::NonDigit { pos: 3 })
);
assert_eq!(
Ppnum::parse("12312345678 "),
Err(PpnumError::NonDigit { pos: 11 })
);
assert_eq!(Ppnum::parse(""), Err(PpnumError::Empty));
}
#[test]
fn unicode_digits_are_not_digits() {
for s in ["12312345678", "١٢٣١٢٣٤٥٦٧٨", "১২৩১২৩৪৫৬৭৮"]
{
assert!(matches!(
Ppnum::parse(s),
Err(PpnumError::NonDigit { pos: 0 })
));
}
}
#[test]
fn from_display_strips_only_hyphens_and_whitespace() {
let want = Ppnum::parse("10000207095").unwrap();
assert_eq!(Ppnum::from_display("100-0020-7095").unwrap(), want);
assert_eq!(Ppnum::from_display("100 0020 7095").unwrap(), want);
assert_eq!(Ppnum::from_display(" 100-0020-7095\t").unwrap(), want);
assert_eq!(Ppnum::from_display("10000207095").unwrap(), want);
assert!(matches!(
Ppnum::from_display("@bot-100-0020-7095"),
Err(PpnumError::NonDigit { pos: 0 })
));
assert!(matches!(
Ppnum::from_display("kr:010-1234-5678"),
Err(PpnumError::NonDigit { .. })
));
assert_eq!(Ppnum::from_display("- -"), Err(PpnumError::Empty));
}
#[test]
fn display_form_groups_three_four_four_then_fours() {
assert_eq!(Ppnum::display_form("77712345678"), "777-1234-5678");
assert_eq!(Ppnum::display_form("100382917500001"), "100-3829-1750-0001");
assert_eq!(
Ppnum::display_form("1003829175000010001"),
"100-3829-1750-0001-0001"
);
assert_eq!(
Ppnum::display_form("10038291750000100015678"),
"100-3829-1750-0001-0001-5678"
);
assert_eq!(Ppnum::display_form("123456789012"), "123-4567-8901-2");
}
#[test]
fn display_form_echoes_anything_that_is_not_a_ppnum() {
for s in ["123", "", "abc", "777-1234-5678", "1234567890"] {
assert_eq!(Ppnum::display_form(s), s);
}
}
#[test]
fn display_and_from_display_round_trip() {
for s in ["77712345678", "100382917500001", "1003829175000010001"] {
let p = Ppnum::parse(s).unwrap();
assert_eq!(Ppnum::from_display(&p.display()).unwrap(), p);
assert_eq!(p.to_string(), s, "Display is the wire form");
}
}
#[test]
fn errors_render_the_rule_they_name() {
assert_eq!(PpnumError::Empty.to_string(), "ppnum is empty");
assert_eq!(
PpnumError::TooShort { len: 10 }.to_string(),
"ppnum too short (10 digits, minimum 11)"
);
assert_eq!(
PpnumError::NonDigit { pos: 3 }.to_string(),
"ppnum contains non-digit character at position 3"
);
}
#[test]
fn ppnum_id_round_trips_through_its_string_form() {
let id = PpnumId::new();
assert_eq!(id.to_string().len(), 26);
assert_eq!(id.to_string().parse::<PpnumId>().unwrap(), id);
assert!("not-a-ulid".parse::<PpnumId>().is_err());
assert_eq!(PpnumId::nil().to_string(), "00000000000000000000000000");
assert_ne!(PpnumId::new(), PpnumId::new());
}
#[cfg(feature = "serde")]
#[test]
fn serde_forms_are_the_wire_forms() {
let p = Ppnum::parse("12312345678").unwrap();
let json = serde_json::to_string(&p).unwrap();
assert_eq!(json, "\"12312345678\"");
assert_eq!(serde_json::from_str::<Ppnum>(&json).unwrap(), p);
for bad in ["\"123\"", "\"abc12345678\"", "\"\"", "\"123-1234-5678\""] {
assert!(serde_json::from_str::<Ppnum>(bad).is_err(), "{bad}");
}
let id = PpnumId::nil();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, format!("\"{id}\""));
assert_eq!(serde_json::from_str::<PpnumId>(&json).unwrap(), id);
}
}