Skip to main content

smart_package_tracker/id/
mod.rs

1//! Tracking identifiers.
2//!
3//! [`TrackingId`] is a validated newtype over a `PREFIX-BODY` string such as
4//! `PKG-9ED9285C`. [`IdGenerator`] produces them according to a configurable
5//! policy (prefix, entropy width, optional check character).
6
7mod checksum;
8mod generator;
9
10pub use generator::{Checksum, IdGenerator, IdGeneratorBuilder};
11
12use alloc::string::{String, ToString};
13use core::fmt;
14use core::str::FromStr;
15
16use crate::error::{Error, Result};
17
18/// Character separating the prefix from the body.
19const SEPARATOR: char = '-';
20/// Upper bound on a whole ID, to keep parsing and barcode widths bounded.
21const MAX_LEN: usize = 128;
22
23/// Characters permitted in a prefix or body.
24///
25/// Restricted to uppercase alphanumerics so that IDs survive case-insensitive
26/// systems, encode compactly in Code 128, and remain unambiguous when read
27/// aloud or hand-keyed.
28fn is_body_char(c: char) -> bool {
29    c.is_ascii_digit() || c.is_ascii_uppercase()
30}
31
32/// A validated package tracking identifier, e.g. `PKG-9ED9285C`.
33///
34/// Construct one with [`TrackingId::generate`] (default policy),
35/// [`IdGenerator::generate`] (custom policy), or [`TrackingId::parse`] when
36/// reading an ID back from storage or user input.
37///
38/// # Examples
39///
40/// ```
41/// # #[cfg(feature = "os-rng")]
42/// # fn main() -> Result<(), smart_package_tracker::Error> {
43/// use smart_package_tracker::TrackingId;
44///
45/// let id = TrackingId::generate()?;
46/// assert!(id.as_str().starts_with("PKG-"));
47///
48/// let parsed: TrackingId = "PKG-9ED9285C".parse()?;
49/// assert_eq!(parsed.prefix(), "PKG");
50/// assert_eq!(parsed.body(), "9ED9285C");
51/// # Ok(())
52/// # }
53/// # #[cfg(not(feature = "os-rng"))]
54/// # fn main() {}
55/// ```
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58#[cfg_attr(feature = "serde", serde(transparent))]
59pub struct TrackingId(pub(crate) String);
60
61impl TrackingId {
62    /// Generate an ID with the default policy: `PKG-` plus 32 bits of entropy.
63    ///
64    /// See [`IdGenerator`] for why 32 bits is often too narrow, and how to
65    /// widen it. Requires the `os-rng` feature (enabled by default).
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Error::Entropy`] if the OS entropy source is unavailable.
70    #[cfg(feature = "os-rng")]
71    pub fn generate() -> Result<Self> {
72        IdGenerator::default().generate()
73    }
74
75    /// Parse and validate an existing ID.
76    ///
77    /// Validation is structural: one separator, a non-empty prefix and body,
78    /// only `A-Z` and `0-9`, and a bounded total length. It deliberately does
79    /// *not* check entropy width or check characters, because those are
80    /// properties of a generator policy rather than of the format — use
81    /// [`IdGenerator::validate`] for that.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`Error::InvalidTrackingId`] describing the first violation.
86    pub fn parse(raw: &str) -> Result<Self> {
87        let invalid = |reason: &str| Error::InvalidTrackingId {
88            reason: reason.to_string(),
89        };
90
91        if raw.is_empty() {
92            return Err(invalid("id is empty"));
93        }
94        if raw.len() > MAX_LEN {
95            return Err(Error::InvalidTrackingId {
96                reason: alloc::format!("id is longer than {MAX_LEN} characters"),
97            });
98        }
99
100        let mut parts = raw.split(SEPARATOR);
101        let prefix = parts.next().unwrap_or_default();
102        let body = parts.next().ok_or_else(|| Error::InvalidTrackingId {
103            reason: alloc::format!("id must contain a `{SEPARATOR}` separator"),
104        })?;
105        if parts.next().is_some() {
106            return Err(Error::InvalidTrackingId {
107                reason: alloc::format!("id must contain exactly one `{SEPARATOR}` separator"),
108            });
109        }
110
111        if prefix.is_empty() {
112            return Err(invalid("prefix must not be empty"));
113        }
114        if body.is_empty() {
115            return Err(invalid("body must not be empty"));
116        }
117        if let Some(bad) = raw
118            .chars()
119            .filter(|c| *c != SEPARATOR)
120            .find(|c| !is_body_char(*c))
121        {
122            return Err(Error::InvalidTrackingId {
123                reason: alloc::format!("`{bad}` is not allowed; use `A-Z` and `0-9`"),
124            });
125        }
126
127        Ok(Self(raw.to_string()))
128    }
129
130    /// The full ID as a string slice.
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134
135    /// The part before the separator, e.g. `PKG`.
136    pub fn prefix(&self) -> &str {
137        self.0.split(SEPARATOR).next().unwrap_or_default()
138    }
139
140    /// The part after the separator, e.g. `9ED9285C`.
141    pub fn body(&self) -> &str {
142        self.0.split(SEPARATOR).nth(1).unwrap_or_default()
143    }
144
145    /// Consume the ID and return the underlying `String`.
146    pub fn into_string(self) -> String {
147        self.0
148    }
149}
150
151impl fmt::Display for TrackingId {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.write_str(&self.0)
154    }
155}
156
157impl AsRef<str> for TrackingId {
158    fn as_ref(&self) -> &str {
159        &self.0
160    }
161}
162
163impl FromStr for TrackingId {
164    type Err = Error;
165
166    fn from_str(s: &str) -> Result<Self> {
167        Self::parse(s)
168    }
169}
170
171impl TryFrom<&str> for TrackingId {
172    type Error = Error;
173
174    fn try_from(s: &str) -> Result<Self> {
175        Self::parse(s)
176    }
177}
178
179impl TryFrom<String> for TrackingId {
180    type Error = Error;
181
182    fn try_from(s: String) -> Result<Self> {
183        Self::parse(&s)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use alloc::string::String;
191
192    #[test]
193    fn accepts_well_formed_ids() {
194        for raw in ["PKG-9ED9285C", "A-0", "BOX-FFFFFFFFFFFFFFFF", "PKG2-ABC123"] {
195            TrackingId::parse(raw).unwrap_or_else(|e| panic!("{raw} rejected: {e}"));
196        }
197    }
198
199    #[test]
200    fn rejects_malformed_ids() {
201        for raw in [
202            "",             // empty
203            "PKG9ED9285C",  // no separator
204            "-9ED9285C",    // empty prefix
205            "PKG-",         // empty body
206            "PKG-9ED-928",  // two separators
207            "pkg-9ed9285c", // lowercase
208            "PKG-9ED_928",  // illegal character
209            "PKG 9ED9285C", // space instead of separator
210        ] {
211            assert!(TrackingId::parse(raw).is_err(), "{raw} should be rejected");
212        }
213    }
214
215    #[test]
216    fn rejects_overlong_ids() {
217        let long: String = "A".repeat(MAX_LEN + 1);
218        assert!(TrackingId::parse(&alloc::format!("PKG-{long}")).is_err());
219    }
220
221    #[test]
222    fn accessors_agree_with_display() {
223        let id = TrackingId::parse("PKG-9ED9285C").unwrap();
224        assert_eq!(id.prefix(), "PKG");
225        assert_eq!(id.body(), "9ED9285C");
226        assert_eq!(alloc::format!("{id}"), "PKG-9ED9285C");
227        assert_eq!(id.as_str(), id.as_ref());
228        assert_eq!(id.clone().into_string(), "PKG-9ED9285C");
229    }
230
231    #[test]
232    fn parses_via_from_str_and_try_from() {
233        let a: TrackingId = "PKG-9ED9285C".parse().unwrap();
234        let b = TrackingId::try_from("PKG-9ED9285C").unwrap();
235        let c = TrackingId::try_from(String::from("PKG-9ED9285C")).unwrap();
236        assert_eq!(a, b);
237        assert_eq!(b, c);
238    }
239}