mod checksum;
mod generator;
pub use generator::{Checksum, IdGenerator, IdGeneratorBuilder};
use alloc::string::{String, ToString};
use core::fmt;
use core::str::FromStr;
use crate::error::{Error, Result};
const SEPARATOR: char = '-';
pub(crate) const MAX_LEN: usize = 160;
fn is_body_char(c: char) -> bool {
c.is_ascii_digit() || c.is_ascii_uppercase()
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct TrackingId(pub(crate) String);
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for TrackingId {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::parse(&raw).map_err(serde::de::Error::custom)
}
}
impl TrackingId {
#[cfg(feature = "os-rng")]
pub fn generate() -> Result<Self> {
IdGenerator::default().generate()
}
pub fn parse(raw: &str) -> Result<Self> {
let invalid = |reason: &str| Error::InvalidTrackingId {
reason: reason.to_string(),
};
if raw.is_empty() {
return Err(invalid("id is empty"));
}
if raw.len() > MAX_LEN {
return Err(Error::InvalidTrackingId {
reason: alloc::format!("id is longer than {MAX_LEN} characters"),
});
}
let mut parts = raw.split(SEPARATOR);
let prefix = parts.next().unwrap_or_default();
let body = parts.next().ok_or_else(|| Error::InvalidTrackingId {
reason: alloc::format!("id must contain a `{SEPARATOR}` separator"),
})?;
if parts.next().is_some() {
return Err(Error::InvalidTrackingId {
reason: alloc::format!("id must contain exactly one `{SEPARATOR}` separator"),
});
}
if prefix.is_empty() {
return Err(invalid("prefix must not be empty"));
}
if body.is_empty() {
return Err(invalid("body must not be empty"));
}
if let Some(bad) = raw
.chars()
.filter(|c| *c != SEPARATOR)
.find(|c| !is_body_char(*c))
{
return Err(Error::InvalidTrackingId {
reason: alloc::format!("`{bad}` is not allowed; use `A-Z` and `0-9`"),
});
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn prefix(&self) -> &str {
self.0.split(SEPARATOR).next().unwrap_or_default()
}
pub fn body(&self) -> &str {
self.0.split(SEPARATOR).nth(1).unwrap_or_default()
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Display for TrackingId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for TrackingId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl FromStr for TrackingId {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s)
}
}
impl TryFrom<&str> for TrackingId {
type Error = Error;
fn try_from(s: &str) -> Result<Self> {
Self::parse(s)
}
}
impl TryFrom<String> for TrackingId {
type Error = Error;
fn try_from(s: String) -> Result<Self> {
Self::parse(&s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::string::String;
#[test]
fn accepts_well_formed_ids() {
for raw in ["PKG-9ED9285C", "A-0", "BOX-FFFFFFFFFFFFFFFF", "PKG2-ABC123"] {
TrackingId::parse(raw).unwrap_or_else(|e| panic!("{raw} rejected: {e}"));
}
}
#[test]
fn rejects_malformed_ids() {
for raw in [
"", "PKG9ED9285C", "-9ED9285C", "PKG-", "PKG-9ED-928", "pkg-9ed9285c", "PKG-9ED_928", "PKG 9ED9285C", ] {
assert!(TrackingId::parse(raw).is_err(), "{raw} should be rejected");
}
}
#[test]
fn rejects_overlong_ids() {
let long: String = "A".repeat(MAX_LEN + 1);
assert!(TrackingId::parse(&alloc::format!("PKG-{long}")).is_err());
}
#[test]
fn accessors_agree_with_display() {
let id = TrackingId::parse("PKG-9ED9285C").unwrap();
assert_eq!(id.prefix(), "PKG");
assert_eq!(id.body(), "9ED9285C");
assert_eq!(alloc::format!("{id}"), "PKG-9ED9285C");
assert_eq!(id.as_str(), id.as_ref());
assert_eq!(id.clone().into_string(), "PKG-9ED9285C");
}
#[test]
fn parses_via_from_str_and_try_from() {
let a: TrackingId = "PKG-9ED9285C".parse().unwrap();
let b = TrackingId::try_from("PKG-9ED9285C").unwrap();
let c = TrackingId::try_from(String::from("PKG-9ED9285C")).unwrap();
assert_eq!(a, b);
assert_eq!(b, c);
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use super::*;
use serde::de::value::{Error as ValueError, StrDeserializer};
use serde::de::IntoDeserializer;
use serde::Deserialize;
fn from_str(raw: &str) -> Result<TrackingId> {
let de: StrDeserializer<'_, ValueError> = raw.into_deserializer();
TrackingId::deserialize(de).map_err(|e| Error::InvalidTrackingId {
reason: alloc::string::ToString::to_string(&e),
})
}
#[test]
fn deserialising_validates_like_parse() {
for raw in [
"", "PKG9ED9285C", "pkg-lowercase", "no-separator-here", "total garbage!!", ] {
assert!(
from_str(raw).is_err(),
"`{raw}` must not deserialise into a TrackingId"
);
}
}
#[test]
fn well_formed_ids_still_deserialise() {
let id = from_str("PKG-9ED9285C").unwrap();
assert_eq!(id, TrackingId::parse("PKG-9ED9285C").unwrap());
assert_eq!(id.prefix(), "PKG");
assert_eq!(id.body(), "9ED9285C");
}
}