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.
21///
22/// It has to cover the widest policy [`IdGenerator`] can be built with — a
23/// 16-character prefix, the separator, 128 hexadecimal characters (512 bits of
24/// entropy) and a check character, which comes to 146 — or the generator could
25/// mint IDs that [`TrackingId::parse`] refuses to read back. A static assertion
26/// in `generator` holds the two limits together.
27pub(crate) const MAX_LEN: usize = 160;
28
29/// Characters permitted in a prefix or body.
30///
31/// Restricted to uppercase alphanumerics so that IDs survive case-insensitive
32/// systems, encode compactly in Code 128, and remain unambiguous when read
33/// aloud or hand-keyed.
34fn is_body_char(c: char) -> bool {
35    c.is_ascii_digit() || c.is_ascii_uppercase()
36}
37
38/// A validated package tracking identifier, e.g. `PKG-9ED9285C`.
39///
40/// Construct one with [`TrackingId::generate`] (default policy),
41/// [`IdGenerator::generate`] (custom policy), or [`TrackingId::parse`] when
42/// reading an ID back from storage or user input.
43///
44/// With the `serde` feature an ID serialises as a plain string, and
45/// deserialising runs the same validation as [`TrackingId::parse`] — a
46/// `TrackingId` that arrived over the wire is as trustworthy as one built by
47/// hand.
48///
49/// # Examples
50///
51/// ```
52/// # #[cfg(feature = "os-rng")]
53/// # fn main() -> Result<(), smart_package_tracker::Error> {
54/// use smart_package_tracker::TrackingId;
55///
56/// let id = TrackingId::generate()?;
57/// assert!(id.as_str().starts_with("PKG-"));
58///
59/// let parsed: TrackingId = "PKG-9ED9285C".parse()?;
60/// assert_eq!(parsed.prefix(), "PKG");
61/// assert_eq!(parsed.body(), "9ED9285C");
62/// # Ok(())
63/// # }
64/// # #[cfg(not(feature = "os-rng"))]
65/// # fn main() {}
66/// ```
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize))]
69#[cfg_attr(feature = "serde", serde(transparent))]
70pub struct TrackingId(pub(crate) String);
71
72#[cfg(feature = "serde")]
73impl<'de> serde::Deserialize<'de> for TrackingId {
74    /// Deserialise and validate.
75    ///
76    /// Deriving this would accept any string at all, which would let a value
77    /// that [`TrackingId::parse`] rejects — a lowercase ID, or one carrying a
78    /// second separator whose tail is then silently ignored by
79    /// [`body`](TrackingId::body) — enter the type through the back door.
80    fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
81    where
82        D: serde::Deserializer<'de>,
83    {
84        let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
85        Self::parse(&raw).map_err(serde::de::Error::custom)
86    }
87}
88
89impl TrackingId {
90    /// Generate an ID with the default policy: `PKG-` plus 32 bits of entropy.
91    ///
92    /// See [`IdGenerator`] for why 32 bits is often too narrow, and how to
93    /// widen it. Requires the `os-rng` feature (enabled by default).
94    ///
95    /// # Errors
96    ///
97    /// Returns [`Error::Entropy`] if the OS entropy source is unavailable.
98    #[cfg(feature = "os-rng")]
99    pub fn generate() -> Result<Self> {
100        IdGenerator::default().generate()
101    }
102
103    /// Parse and validate an existing ID.
104    ///
105    /// Validation is structural: one separator, a non-empty prefix and body,
106    /// only `A-Z` and `0-9`, and a bounded total length. It deliberately does
107    /// *not* check entropy width or check characters, because those are
108    /// properties of a generator policy rather than of the format — use
109    /// [`IdGenerator::validate`] for that.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`Error::InvalidTrackingId`] describing the first violation.
114    pub fn parse(raw: &str) -> Result<Self> {
115        let invalid = |reason: &str| Error::InvalidTrackingId {
116            reason: reason.to_string(),
117        };
118
119        if raw.is_empty() {
120            return Err(invalid("id is empty"));
121        }
122        if raw.len() > MAX_LEN {
123            return Err(Error::InvalidTrackingId {
124                reason: alloc::format!("id is longer than {MAX_LEN} characters"),
125            });
126        }
127
128        let mut parts = raw.split(SEPARATOR);
129        let prefix = parts.next().unwrap_or_default();
130        let body = parts.next().ok_or_else(|| Error::InvalidTrackingId {
131            reason: alloc::format!("id must contain a `{SEPARATOR}` separator"),
132        })?;
133        if parts.next().is_some() {
134            return Err(Error::InvalidTrackingId {
135                reason: alloc::format!("id must contain exactly one `{SEPARATOR}` separator"),
136            });
137        }
138
139        if prefix.is_empty() {
140            return Err(invalid("prefix must not be empty"));
141        }
142        if body.is_empty() {
143            return Err(invalid("body must not be empty"));
144        }
145        if let Some(bad) = raw
146            .chars()
147            .filter(|c| *c != SEPARATOR)
148            .find(|c| !is_body_char(*c))
149        {
150            return Err(Error::InvalidTrackingId {
151                reason: alloc::format!("`{bad}` is not allowed; use `A-Z` and `0-9`"),
152            });
153        }
154
155        Ok(Self(raw.to_string()))
156    }
157
158    /// The full ID as a string slice.
159    pub fn as_str(&self) -> &str {
160        &self.0
161    }
162
163    /// The part before the separator, e.g. `PKG`.
164    pub fn prefix(&self) -> &str {
165        self.0.split(SEPARATOR).next().unwrap_or_default()
166    }
167
168    /// The part after the separator, e.g. `9ED9285C`.
169    pub fn body(&self) -> &str {
170        self.0.split(SEPARATOR).nth(1).unwrap_or_default()
171    }
172
173    /// Consume the ID and return the underlying `String`.
174    pub fn into_string(self) -> String {
175        self.0
176    }
177}
178
179impl fmt::Display for TrackingId {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        f.write_str(&self.0)
182    }
183}
184
185impl AsRef<str> for TrackingId {
186    fn as_ref(&self) -> &str {
187        &self.0
188    }
189}
190
191impl FromStr for TrackingId {
192    type Err = Error;
193
194    fn from_str(s: &str) -> Result<Self> {
195        Self::parse(s)
196    }
197}
198
199impl TryFrom<&str> for TrackingId {
200    type Error = Error;
201
202    fn try_from(s: &str) -> Result<Self> {
203        Self::parse(s)
204    }
205}
206
207impl TryFrom<String> for TrackingId {
208    type Error = Error;
209
210    fn try_from(s: String) -> Result<Self> {
211        Self::parse(&s)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use alloc::string::String;
219
220    #[test]
221    fn accepts_well_formed_ids() {
222        for raw in ["PKG-9ED9285C", "A-0", "BOX-FFFFFFFFFFFFFFFF", "PKG2-ABC123"] {
223            TrackingId::parse(raw).unwrap_or_else(|e| panic!("{raw} rejected: {e}"));
224        }
225    }
226
227    #[test]
228    fn rejects_malformed_ids() {
229        for raw in [
230            "",             // empty
231            "PKG9ED9285C",  // no separator
232            "-9ED9285C",    // empty prefix
233            "PKG-",         // empty body
234            "PKG-9ED-928",  // two separators
235            "pkg-9ed9285c", // lowercase
236            "PKG-9ED_928",  // illegal character
237            "PKG 9ED9285C", // space instead of separator
238        ] {
239            assert!(TrackingId::parse(raw).is_err(), "{raw} should be rejected");
240        }
241    }
242
243    #[test]
244    fn rejects_overlong_ids() {
245        let long: String = "A".repeat(MAX_LEN + 1);
246        assert!(TrackingId::parse(&alloc::format!("PKG-{long}")).is_err());
247    }
248
249    #[test]
250    fn accessors_agree_with_display() {
251        let id = TrackingId::parse("PKG-9ED9285C").unwrap();
252        assert_eq!(id.prefix(), "PKG");
253        assert_eq!(id.body(), "9ED9285C");
254        assert_eq!(alloc::format!("{id}"), "PKG-9ED9285C");
255        assert_eq!(id.as_str(), id.as_ref());
256        assert_eq!(id.clone().into_string(), "PKG-9ED9285C");
257    }
258
259    #[test]
260    fn parses_via_from_str_and_try_from() {
261        let a: TrackingId = "PKG-9ED9285C".parse().unwrap();
262        let b = TrackingId::try_from("PKG-9ED9285C").unwrap();
263        let c = TrackingId::try_from(String::from("PKG-9ED9285C")).unwrap();
264        assert_eq!(a, b);
265        assert_eq!(b, c);
266    }
267}
268
269#[cfg(all(test, feature = "serde"))]
270mod serde_tests {
271    use super::*;
272    use serde::de::value::{Error as ValueError, StrDeserializer};
273    use serde::de::IntoDeserializer;
274    use serde::Deserialize;
275
276    fn from_str(raw: &str) -> Result<TrackingId> {
277        let de: StrDeserializer<'_, ValueError> = raw.into_deserializer();
278        TrackingId::deserialize(de).map_err(|e| Error::InvalidTrackingId {
279            reason: alloc::string::ToString::to_string(&e),
280        })
281    }
282
283    #[test]
284    fn deserialising_validates_like_parse() {
285        for raw in [
286            "",                  // empty
287            "PKG9ED9285C",       // no separator
288            "pkg-lowercase",     // lowercase
289            "no-separator-here", // two separators: `body()` would drop the tail
290            "total garbage!!",   // illegal characters
291        ] {
292            assert!(
293                from_str(raw).is_err(),
294                "`{raw}` must not deserialise into a TrackingId"
295            );
296        }
297    }
298
299    #[test]
300    fn well_formed_ids_still_deserialise() {
301        let id = from_str("PKG-9ED9285C").unwrap();
302        assert_eq!(id, TrackingId::parse("PKG-9ED9285C").unwrap());
303        assert_eq!(id.prefix(), "PKG");
304        assert_eq!(id.body(), "9ED9285C");
305    }
306}