smart-package-tracker 0.4.0

Generate package tracking IDs, render them as Code 128 barcodes (PNG and SVG), and read them back out of images
Documentation
//! Tracking identifiers.
//!
//! [`TrackingId`] is a validated newtype over a `PREFIX-BODY` string such as
//! `PKG-9ED9285C`. [`IdGenerator`] produces them according to a configurable
//! policy (prefix, entropy width, optional check character).

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};

/// Character separating the prefix from the body.
const SEPARATOR: char = '-';
/// Upper bound on a whole ID, to keep parsing and barcode widths bounded.
///
/// It has to cover the widest policy [`IdGenerator`] can be built with — a
/// 16-character prefix, the separator, 128 hexadecimal characters (512 bits of
/// entropy) and a check character, which comes to 146 — or the generator could
/// mint IDs that [`TrackingId::parse`] refuses to read back. A static assertion
/// in `generator` holds the two limits together.
pub(crate) const MAX_LEN: usize = 160;

/// Characters permitted in a prefix or body.
///
/// Restricted to uppercase alphanumerics so that IDs survive case-insensitive
/// systems, encode compactly in Code 128, and remain unambiguous when read
/// aloud or hand-keyed.
fn is_body_char(c: char) -> bool {
    c.is_ascii_digit() || c.is_ascii_uppercase()
}

/// A validated package tracking identifier, e.g. `PKG-9ED9285C`.
///
/// Construct one with [`TrackingId::generate`] (default policy),
/// [`IdGenerator::generate`] (custom policy), or [`TrackingId::parse`] when
/// reading an ID back from storage or user input.
///
/// With the `serde` feature an ID serialises as a plain string, and
/// deserialising runs the same validation as [`TrackingId::parse`] — a
/// `TrackingId` that arrived over the wire is as trustworthy as one built by
/// hand.
///
/// # Examples
///
/// ```
/// # #[cfg(feature = "os-rng")]
/// # fn main() -> Result<(), smart_package_tracker::Error> {
/// use smart_package_tracker::TrackingId;
///
/// let id = TrackingId::generate()?;
/// assert!(id.as_str().starts_with("PKG-"));
///
/// let parsed: TrackingId = "PKG-9ED9285C".parse()?;
/// assert_eq!(parsed.prefix(), "PKG");
/// assert_eq!(parsed.body(), "9ED9285C");
/// # Ok(())
/// # }
/// # #[cfg(not(feature = "os-rng"))]
/// # fn main() {}
/// ```
#[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 {
    /// Deserialise and validate.
    ///
    /// Deriving this would accept any string at all, which would let a value
    /// that [`TrackingId::parse`] rejects — a lowercase ID, or one carrying a
    /// second separator whose tail is then silently ignored by
    /// [`body`](TrackingId::body) — enter the type through the back door.
    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 {
    /// Generate an ID with the default policy: `PKG-` plus 32 bits of entropy.
    ///
    /// See [`IdGenerator`] for why 32 bits is often too narrow, and how to
    /// widen it. Requires the `os-rng` feature (enabled by default).
    ///
    /// # Errors
    ///
    /// Returns [`Error::Entropy`] if the OS entropy source is unavailable.
    #[cfg(feature = "os-rng")]
    pub fn generate() -> Result<Self> {
        IdGenerator::default().generate()
    }

    /// Parse and validate an existing ID.
    ///
    /// Validation is structural: one separator, a non-empty prefix and body,
    /// only `A-Z` and `0-9`, and a bounded total length. It deliberately does
    /// *not* check entropy width or check characters, because those are
    /// properties of a generator policy rather than of the format — use
    /// [`IdGenerator::validate`] for that.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidTrackingId`] describing the first violation.
    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()))
    }

    /// The full ID as a string slice.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// The part before the separator, e.g. `PKG`.
    pub fn prefix(&self) -> &str {
        self.0.split(SEPARATOR).next().unwrap_or_default()
    }

    /// The part after the separator, e.g. `9ED9285C`.
    pub fn body(&self) -> &str {
        self.0.split(SEPARATOR).nth(1).unwrap_or_default()
    }

    /// Consume the ID and return the underlying `String`.
    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 [
            "",             // empty
            "PKG9ED9285C",  // no separator
            "-9ED9285C",    // empty prefix
            "PKG-",         // empty body
            "PKG-9ED-928",  // two separators
            "pkg-9ed9285c", // lowercase
            "PKG-9ED_928",  // illegal character
            "PKG 9ED9285C", // space instead of separator
        ] {
            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 [
            "",                  // empty
            "PKG9ED9285C",       // no separator
            "pkg-lowercase",     // lowercase
            "no-separator-here", // two separators: `body()` would drop the tail
            "total garbage!!",   // illegal characters
        ] {
            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");
    }
}