Skip to main content

tracking_numbers/
lib.rs

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(&regex_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,  // Catch all other fields
100}
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                // Compile regex for prepend_if check
165                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        // Test with a UPS tracking number
370        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        // Test with a Canada Post tracking number
382        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}