1mod validators;
2
3use include_dir::{include_dir, Dir};
4use lazy_static::lazy_static;
5use log::{debug, info, warn};
6use pcre2::bytes::Regex;
7use serde::{Deserialize, Deserializer};
8use serde_json;
9use validators::Validator;
10
11static COURIERS: Dir<'_> = include_dir!("tracking_number_data/couriers/");
12
13lazy_static! {
14 static ref COURIERS_CACHE: Vec<Courier> = load_couriers();
15}
16
17#[derive(Deserialize, Debug)]
18pub struct TrackingResult {
19 pub courier: String,
20 pub service: String,
21 pub tracking_number: String,
22 pub tracking_url: String,
23}
24
25#[derive(Deserialize, Debug)]
26struct Courier {
27 name: String,
28 #[serde(rename = "courier_code")]
29 code: String,
30 tracking_numbers: Vec<TrackingNumber>,
31}
32
33fn deserialize_pcre<'de, D>(deserializer: D) -> Result<Regex, D::Error>
34where
35 D: Deserializer<'de>,
36{
37 #[derive(Deserialize)]
38 #[serde(untagged)]
39 enum RawRegex {
40 Single(String),
41 Multi(Vec<String>),
42 }
43
44 let raw = RawRegex::deserialize(deserializer)?;
45 let regex_str = match raw {
46 RawRegex::Single(s) => s,
47 RawRegex::Multi(v) => v.join("")
48 };
49
50 Regex::new(®ex_str).map_err(|e| {
51 serde::de::Error::custom(format!("Invalid PCRE2 regex '{}': {}", regex_str, e))
52 })
53}
54
55#[derive(Deserialize, Debug)]
56struct TrackingNumber {
57 name: String,
58 #[serde(deserialize_with = "deserialize_pcre")]
59 regex: Regex,
60 #[cfg(test)]
61 test_numbers: TestNumbers,
62 tracking_url: Option<String>,
63 validation: Validation,
64 #[serde(default)]
65 additional: Vec<AdditionalLookup>,
66}
67
68#[allow(dead_code)]
69#[derive(Deserialize, Debug)]
70struct TestNumbers {
71 pub valid: Vec<String>,
72 pub invalid: Vec<String>,
73}
74
75#[derive(Deserialize, Debug)]
76struct Validation {
77 checksum: Option<Checksum>,
78 serial_number_format: Option<SerialNumberFormat>,
79 additional: Option<AdditionalValidation>,
80}
81
82#[derive(Debug, Deserialize)]
83struct AdditionalValidation {
84 exists: Option<Vec<String>>,
85}
86
87#[derive(Debug, Deserialize)]
88struct AdditionalLookup {
89 name: String,
90 regex_group_name: String,
91 lookup: Vec<LookupEntry>,
92}
93
94#[derive(Debug, Deserialize)]
95struct LookupEntry {
96 matches: Option<String>,
97 matches_regex: Option<String>,
98 #[serde(flatten)]
99 _extra: serde_json::Value, }
101
102#[derive(Debug, Deserialize)]
103struct SerialNumberFormat {
104 #[serde(default)]
105 prepend_if: Option<PrependIf>,
106}
107
108#[derive(Debug, Deserialize)]
109struct PrependIf {
110 matches_regex: String,
111 content: String,
112}
113
114#[derive(Debug, Deserialize)]
115#[serde(tag = "name")]
116enum Checksum {
117 #[serde(rename = "mod10")]
118 Mod10 {
119 evens_multiplier: u32,
120 odds_multiplier: u32,
121 #[serde(default)]
122 reverse: bool,
123 },
124
125 #[serde(rename = "mod7")]
126 Mod7,
127
128 #[serde(rename = "sum_product_with_weightings_and_modulo")]
129 SumProduct {
130 weightings: Vec<u32>,
131 modulo1: u32,
132 modulo2: u32,
133 },
134
135 #[serde(rename = "s10")]
136 S10,
137
138 #[serde(rename = "mod_37_36")]
139 Mod37_36,
140
141 #[serde(rename = "luhn")]
142 Luhn,
143}
144
145impl TrackingNumber {
146 fn extract_captures(&self, tracking_number: &str) -> Option<(String, String)> {
147 let captures = self.regex.captures(tracking_number.as_bytes()).ok()??;
148
149 let serial = self.get_named_capture(&captures, "SerialNumber")?;
150 let check_digit = self.get_named_capture(&captures, "CheckDigit")?;
151
152 Some((serial, check_digit))
153 }
154
155 fn get_named_capture(&self, captures: &pcre2::bytes::Captures, name: &str) -> Option<String> {
156 captures.name(name)
157 .and_then(|m| std::str::from_utf8(m.as_bytes()).ok())
158 .map(|s| s.chars().filter(|c| !c.is_whitespace()).collect())
159 }
160
161 fn apply_serial_number_format(&self, serial: &str) -> String {
162 if let Some(format) = &self.validation.serial_number_format {
163 if let Some(prepend) = &format.prepend_if {
164 if let Ok(regex) = Regex::new(&prepend.matches_regex) {
166 if regex.is_match(serial.as_bytes()).unwrap_or(false) {
167 return format!("{}{}", prepend.content, serial);
168 }
169 }
170 }
171 }
172 serial.to_string()
173 }
174
175 fn check_format(&self, tracking_number: &str) -> bool {
176 let input_bytes = tracking_number.as_bytes();
177 let result = self.regex.is_match(input_bytes).unwrap_or(false);
178
179 return result;
180 }
181
182 fn check_validation(&self, tracking_number: &str) -> bool {
183 let Some(checksum) = &self.validation.checksum else {
184 return true;
185 };
186
187 let Some((serial, check_digit)) = self.extract_captures(tracking_number) else {
188 debug!("Failed to extract captures for {}", tracking_number);
189 return false;
190 };
191
192 let serial = self.apply_serial_number_format(&serial);
193
194 match checksum {
195 Checksum::Mod10 { evens_multiplier, odds_multiplier, reverse } => {
196 validators::Mod10 {
197 evens_multiplier: *evens_multiplier,
198 odds_multiplier: *odds_multiplier,
199 reverse: *reverse,
200 }.validate(&serial, &check_digit)
201 }
202
203 Checksum::Mod7 => {
204 validators::Mod7.validate(&serial, &check_digit)
205 }
206
207 Checksum::SumProduct { weightings, modulo1, modulo2 } => {
208 validators::SumProduct {
209 weightings: weightings.clone(),
210 modulo1: *modulo1,
211 modulo2: *modulo2,
212 }.validate(&serial, &check_digit)
213 }
214
215 Checksum::S10 => {
216 validators::S10.validate(&serial, &check_digit)
217 }
218
219 Checksum::Luhn => {
220 validators::Luhn.validate(&serial, &check_digit)
221 }
222
223 Checksum::Mod37_36 => {
224 validators::Mod37_36.validate(&serial, &check_digit)
225 }
226 }
227 }
228
229 fn check_additional(&self, tracking_number: &str) -> bool {
230 let Some(additional_validation) = &self.validation.additional else {
231 return true;
232 };
233
234 let Some(exists_list) = &additional_validation.exists else {
235 return true;
236 };
237
238 let Ok(Some(captures)) = self.regex.captures(tracking_number.as_bytes()) else {
239 debug!("Failed to extract captures for additional validation");
240 return false;
241 };
242
243 for exists_item in exists_list {
244 let Some(lookup) = self.additional.iter().find(|l| &l.name == exists_item) else {
245 debug!("No lookup found for exists item: {}", exists_item);
246 return false;
247 };
248
249 let Some(group_value) = self.get_named_capture(&captures, &lookup.regex_group_name) else {
250 debug!("Failed to extract regex group: {}", lookup.regex_group_name);
251 return false;
252 };
253
254 let exists = lookup.lookup.iter().any(|entry| {
255 if let Some(ref matches) = entry.matches {
256 if matches == &group_value {
257 return true;
258 }
259 }
260
261 if let Some(ref matches_regex) = entry.matches_regex {
262 if let Ok(regex) = Regex::new(matches_regex) {
263 if regex.is_match(group_value.as_bytes()).unwrap_or(false) {
264 return true;
265 }
266 }
267 }
268
269 false
270 });
271
272 if !exists {
273 debug!("Value '{}' not found in lookup table for '{}'", group_value, exists_item);
274 return false;
275 }
276 }
277
278 true
279 }
280
281 fn is_valid(&self, tracking_number: &str) -> bool {
282 self.check_format(tracking_number) &&
283 self.check_validation(tracking_number) &&
284 self.check_additional(tracking_number)
285 }
286}
287
288pub fn track(trk_num: &str) -> Option<TrackingResult> {
289 info!("Searching for tracking number: {}", trk_num);
290
291 for courier in COURIERS_CACHE.iter() {
292 debug!("Checking {} ({})", courier.name, courier.code);
293 for tn in &courier.tracking_numbers {
294 if tn.is_valid(trk_num) {
295 let tracking_url = tn.tracking_url
296 .as_ref()
297 .map(|url| url.replace("%s", trk_num))
298 .unwrap_or_else(|| String::new());
299
300 return Some(TrackingResult {
301 courier: courier.name.to_string(),
302 service: tn.name.to_string(),
303 tracking_number: trk_num.to_string(),
304 tracking_url,
305 });
306 }
307 }
308 }
309
310 return None
311}
312
313fn load_couriers() -> Vec<Courier> {
314 return COURIERS
315 .files()
316 .map(|file| {
317 debug!("Loading configuration: {}", file.path().display());
318
319 let path = file.path();
320 let content = file.contents_utf8().map(|s| {
321 serde_json::from_str::<Courier>(s)
322 });
323
324 (path, content)
325 })
326 .inspect(|(path, content)| match content {
327 Some(Ok(_)) => (),
328 Some(Err(e)) => warn!("Warning: '{}' is invalid JSON: {}", path.display(), e),
329 None => warn!("Warning: '{}' is not valid UTF-8", path.display()),
330 })
331 .filter_map(|(_, content)| content?.ok())
332 .collect();
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_load() {
341 let result = load_couriers();
342 assert_eq!(result.len(), 12);
343 }
344
345 #[test]
346 fn test_valid_numbers() {
347 for courier in load_couriers() {
348 for tn in courier.tracking_numbers {
349 for valid_num in &tn.test_numbers.valid {
350 assert_eq!(true, tn.is_valid(&valid_num));
351 }
352 }
353 }
354 }
355
356 #[test]
357 fn test_invalid_numbers() {
358 for courier in load_couriers() {
359 for tn in courier.tracking_numbers {
360 for invalid_num in &tn.test_numbers.invalid {
361 assert_eq!(false, tn.is_valid(&invalid_num));
362 }
363 }
364 }
365 }
366
367 #[test]
368 fn test_tracking_url() {
369 let result = track("1Z5R89390357567127");
371 assert!(result.is_some(), "Should find UPS tracking number");
372
373 if let Some(tracking) = result {
374 assert!(tracking.tracking_url.contains("1Z5R89390357567127"),
375 "URL should contain the tracking number");
376 assert!(!tracking.tracking_url.contains("%s"),
377 "URL should not contain the placeholder");
378 println!("UPS URL: {}", tracking.tracking_url);
379 }
380
381 let result = track("0073938000549297");
383 assert!(result.is_some(), "Should find Canada Post tracking number");
384
385 if let Some(tracking) = result {
386 assert!(tracking.tracking_url.contains("0073938000549297"),
387 "URL should contain the tracking number");
388 println!("Canada Post URL: {}", tracking.tracking_url);
389 }
390 }
391}