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 = '-';
20const MAX_LEN: usize = 128;
22
23fn is_body_char(c: char) -> bool {
29 c.is_ascii_digit() || c.is_ascii_uppercase()
30}
31
32#[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 #[cfg(feature = "os-rng")]
71 pub fn generate() -> Result<Self> {
72 IdGenerator::default().generate()
73 }
74
75 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 pub fn as_str(&self) -> &str {
132 &self.0
133 }
134
135 pub fn prefix(&self) -> &str {
137 self.0.split(SEPARATOR).next().unwrap_or_default()
138 }
139
140 pub fn body(&self) -> &str {
142 self.0.split(SEPARATOR).nth(1).unwrap_or_default()
143 }
144
145 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 "", "PKG9ED9285C", "-9ED9285C", "PKG-", "PKG-9ED-928", "pkg-9ed9285c", "PKG-9ED_928", "PKG 9ED9285C", ] {
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}