smart_package_tracker/id/
mod.rs1mod 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
18const SEPARATOR: char = '-';
20pub(crate) const MAX_LEN: usize = 160;
28
29fn is_body_char(c: char) -> bool {
35 c.is_ascii_digit() || c.is_ascii_uppercase()
36}
37
38#[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 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 #[cfg(feature = "os-rng")]
99 pub fn generate() -> Result<Self> {
100 IdGenerator::default().generate()
101 }
102
103 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 pub fn as_str(&self) -> &str {
160 &self.0
161 }
162
163 pub fn prefix(&self) -> &str {
165 self.0.split(SEPARATOR).next().unwrap_or_default()
166 }
167
168 pub fn body(&self) -> &str {
170 self.0.split(SEPARATOR).nth(1).unwrap_or_default()
171 }
172
173 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 "", "PKG9ED9285C", "-9ED9285C", "PKG-", "PKG-9ED-928", "pkg-9ed9285c", "PKG-9ED_928", "PKG 9ED9285C", ] {
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 "", "PKG9ED9285C", "pkg-lowercase", "no-separator-here", "total garbage!!", ] {
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}