worker_matcher/identifiers.rs
1//! National healthcare identifier parsing and validation.
2//!
3//! This module exposes parsers for the national-level healthcare identifiers
4//! that the crate compares deterministically and probabilistically.
5//!
6//! Function names follow the convention `parse_<cc>_<scheme>`, where `<cc>`
7//! is the ISO 3166-1 alpha-2 country code (lower-cased) and `<scheme>` is
8//! the short identifier name. This keeps related schemes alphabetised within
9//! a country, and makes new countries easy to slot in.
10//!
11//! | Jurisdiction | Identifier | Parser |
12//! |---|---|---|
13//! | United Kingdom — England, Wales, Isle of Man | NHS Number | [`parse_uk_nhs_number`] |
14//! | France | NIR (*Numéro d'Inscription au Répertoire*) | [`parse_fr_nir`] |
15//! | España (Spain) | TSI (*Tarjeta Sanitaria Individual*) / CIP-SNS | [`parse_es_tsi`] |
16//! | Éire (Ireland) | IHI (Individual Health Identifier) | [`parse_ie_ihi`] |
17//! | United Kingdom — Northern Ireland | H&C Number (Health and Care Number) | [`parse_uk_hc_number`] |
18//! | United Kingdom — Scotland | CHI (Community Health Index) | [`parse_uk_chi_number`] |
19//! | United Kingdom | NINO (National Insurance Number) | [`parse_uk_nino`] |
20//! | United States | SSN (Social Security Number) | [`parse_us_ssn`] |
21//! | Germany | KVNR (Krankenversichertennummer) | [`parse_de_kvnr`] |
22//! | Italy | *Codice Fiscale* (CF) | [`parse_it_cf`] |
23//! | Netherlands | BSN (*Burgerservicenummer*) | [`parse_nl_bsn`] |
24//! | Sweden | *Workernummer* | [`parse_se_workernummer`] |
25//! | Australia | IHI (Individual Healthcare Identifier) | [`parse_au_ihi`] |
26//! | Belgium | National Number (*Rijksregisternummer*) | [`parse_be_nn`] |
27//! | Bulgaria | EGN (*Edinen grazhdanski nomer*) | [`parse_bg_egn`] |
28//! | Czech Republic | *Rodné číslo* | [`parse_cz_rc`] |
29//! | Denmark | CPR (*Centrale Workerregister*) | [`parse_dk_cpr`] |
30//! | Estonia | *Isikukood* | [`parse_ee_ik`] |
31//! | España (Spain) | DNI/NIE | [`parse_es_dni`] |
32//! | Finland | HETU (*Henkilötunnus*) | [`parse_fi_hetu`] |
33//! | Croatia | OIB (*Osobni identifikacijski broj*) | [`parse_hr_oib`] |
34//! | Iceland | *Kennitala* | [`parse_is_kt`] |
35//! | Lithuania | *Asmens kodas* | [`parse_lt_ak`] |
36//! | Latvia | *Workeras kods* | [`parse_lv_pk`] |
37//! | Malta | National ID | [`parse_mt_id`] |
38//! | Norway | *Fødselsnummer* | [`parse_no_fnr`] |
39//! | Poland | PESEL | [`parse_pl_pesel`] |
40//! | Romania | CNP (*Cod Numeric Workeral*) | [`parse_ro_cnp`] |
41//! | Slovenia | EMŠO (*Enotna Matična Številka Občana*) | [`parse_si_emso`] |
42//! | Slovakia | *Rodné číslo* | [`parse_sk_rc`] |
43//! | Greece | DSS investor share | [`parse_gr_dss`] |
44//! | Liechtenstein | National Identity Card Number | [`parse_li_id`] |
45//! | Netherlands | National Identity Card Number | [`parse_nl_id`] |
46//! | Poland | NIP (*Numer Identyfikacji Podatkowej*) | [`parse_pl_nip`] |
47//! | Portugal | NIF (*Número de Identificação Fiscal*) | [`parse_pt_nif`] |
48//! | Brazil | CPF (*Cadastro de Pessoas Físicas*) | [`parse_br_cpf`] |
49//! | China | RRN (*居民身份证*) 18-digit | [`parse_cn_rrn`] |
50//! | India | Aadhaar | [`parse_in_aadhaar`] |
51//! | Japan | My Number (*個人番号*) | [`parse_jp_my_number`] |
52//! | Mexico | CURP (*Clave Única de Registro de Población*) | [`parse_mx_curp`] |
53//! | New Zealand | NHI (National Health Index) — original 7-char form | [`parse_nz_nhi`] |
54//! | South Africa | ID Number | [`parse_za_id`] |
55//!
56//! ## Passport-number format validators
57//!
58//! Passport book numbers are not stable across renewals, and a worker
59//! may hold passports from several countries simultaneously — see
60//! [`crate::PassportBook`] for the canonical multi-country, multi-book,
61//! time-varying model used by the matcher. The following per-country
62//! parsers are pure **format validators** that consumers can call before
63//! constructing a `PassportBook` (or as a smell test in their own
64//! ingestion code). They do NOT have a corresponding `Worker` field;
65//! they exist so a country-specific passport number can be canonicalised
66//! and rejected at the system boundary.
67//!
68//! | Jurisdiction | Format | Parser |
69//! |---|---|---|
70//! | Cyprus | `E` + 6 digits (pre-2010) or `K` + 8 digits | [`parse_cy_passport`] |
71//! | Czech Republic | 8 to 12 digits | [`parse_cz_passport`] |
72//! | Liechtenstein | 1 letter + 5 digits | [`parse_li_passport`] |
73//! | Lithuania | 8 digits | [`parse_lt_passport`] |
74//! | Malta | 7 digits | [`parse_mt_passport`] |
75//! | Netherlands | same shape as the NL ID card | [`parse_nl_passport`] |
76//! | Portugal | 1 letter + 6 digits | [`parse_pt_passport`] |
77//! | Romania | 2 letters + 6 digits | [`parse_ro_passport`] |
78//! | Slovakia | 2 letters + 7 digits | [`parse_sk_passport`] |
79//!
80//! Each parser takes a `&str` and returns `Option<String>`:
81//!
82//! - `Some(canonical)` — the input parses for the identifier scheme. The
83//! returned string is a canonical form (whitespace stripped, letters
84//! uppercased) suitable for byte-equality comparison.
85//! - `None` — the input fails the scheme's structural or check-digit test.
86//!
87//! Two inputs that represent the same identifier in different textual
88//! layouts always canonicalise to the same string. Consumers compare the
89//! canonical forms for equality; the matching engine does exactly this.
90//!
91//! ## Design notes
92//!
93//! - Parsing is **format-only** unless the scheme has an integral check
94//! digit (NIR has a Modulus-97 key; H&C / NHS structurally accept any
95//! 10-digit number through the `nhs-number` crate's `FromStr`).
96//! - These parsers do not consult external registries; they verify only
97//! what can be derived from the identifier's own structure.
98//! - Country-specific semantic ranges (e.g. valid French department codes,
99//! valid Spanish autonomous-community prefixes) are deliberately NOT
100//! enforced to avoid rejecting edge-case-but-legitimate values.
101//!
102//! ## Example
103//!
104//! ```
105//! use worker_matcher::identifiers;
106//!
107//! // UK NHS Number — accepts the canonical "XXX XXX XXXX" layout.
108//! assert_eq!(
109//! identifiers::parse_uk_nhs_number("943 476 5919"),
110//! Some("9434765919".to_string()),
111//! );
112//!
113//! // Anything that does not match the NHS layout returns None.
114//! assert_eq!(identifiers::parse_uk_nhs_number("not-a-number"), None);
115//! ```
116
117use nhs_number::NHSNumber;
118use std::str::FromStr;
119
120/// Parse a United Kingdom NHS Number (England, Wales, Isle of Man).
121///
122/// Wraps [`nhs_number::NHSNumber::from_str`], which accepts the 10-digit
123/// compact layout (`"9434765919"`) and the spaced layout (`"943 476 5919"`).
124/// On success, the canonical 10-digit form is returned.
125///
126/// The NHS Number applies to England, Wales, and the Isle of Man. Northern
127/// Ireland uses a separate H&C Number that follows the same Modulus-11
128/// algorithm — see [`parse_uk_hc_number`].
129///
130/// # Examples
131///
132/// ```
133/// use worker_matcher::identifiers::parse_uk_nhs_number;
134///
135/// assert_eq!(parse_uk_nhs_number("9434765919"), Some("9434765919".to_string()));
136/// assert_eq!(parse_uk_nhs_number("943 476 5919"), Some("9434765919".to_string()));
137/// assert_eq!(parse_uk_nhs_number("ABCDEFGHIJ"), None);
138/// assert_eq!(parse_uk_nhs_number("123"), None);
139/// ```
140pub fn parse_uk_nhs_number(s: &str) -> Option<String> {
141 let parsed = NHSNumber::from_str(s).ok()?;
142 let mut canonical = String::with_capacity(10);
143 for &d in &parsed.digits {
144 canonical.push(char::from_digit(d as u32, 10)?);
145 }
146 Some(canonical)
147}
148
149/// Parse a France NIR (*Numéro d'Inscription au Répertoire*).
150///
151/// The NIR — also known as the INSEE number or *Numéro de Sécurité Sociale*
152/// — is France's national social-security identifier and the de-facto unique
153/// healthcare identifier. Its structure is:
154///
155/// ```text
156/// S YY MM DD CCC NNN KK
157/// │ │ │ │ │ │ └─ 2-digit check key (Mod-97)
158/// │ │ │ │ │ └───── 3-digit municipal birth-order number
159/// │ │ │ │ └───────── 3-digit commune code
160/// │ │ │ └──────────── 2-digit département (or "2A"/"2B" for Corsica)
161/// │ │ └─────────────── 2-digit month of birth
162/// │ └────────────────── 2-digit year of birth
163/// └──────────────────── sex (1=male, 2=female, plus special values)
164/// ```
165///
166/// Total length is exactly 15 characters. The check key K satisfies
167/// `K = 97 - (N mod 97)`, where N is the 13-digit body. For Corsica, the
168/// department letters are remapped before computing N: `"2A" → "19"`,
169/// `"2B" → "18"`.
170///
171/// Whitespace in the input is stripped before parsing, so the formal layout
172/// `"1 80 12 75 123 456 42"` and the compact `"180127512345642"` both
173/// canonicalise to the same 15-character upper-case string.
174///
175/// # Examples
176///
177/// A canonical, syntactically valid NIR round-trips:
178///
179/// ```
180/// use worker_matcher::identifiers::parse_fr_nir;
181///
182/// // 13-digit body with department 75 (Paris), key computed as 97 - (N mod 97).
183/// let valid = "180127512345642";
184/// assert_eq!(parse_fr_nir(valid), Some(valid.to_string()));
185/// ```
186///
187/// Whitespace is tolerated:
188///
189/// ```
190/// # use worker_matcher::identifiers::parse_fr_nir;
191/// assert_eq!(
192/// parse_fr_nir("1 80 12 75 123 456 42"),
193/// Some("180127512345642".to_string()),
194/// );
195/// ```
196///
197/// An invalid check key rejects:
198///
199/// ```
200/// # use worker_matcher::identifiers::parse_fr_nir;
201/// assert_eq!(parse_fr_nir("180127512345699"), None); // wrong key
202/// assert_eq!(parse_fr_nir("12345"), None); // wrong length
203/// assert_eq!(parse_fr_nir(""), None);
204/// ```
205pub fn parse_fr_nir(s: &str) -> Option<String> {
206 let cleaned: String = s
207 .chars()
208 .filter(|c| !c.is_whitespace())
209 .collect::<String>()
210 .to_uppercase();
211
212 if !cleaned.is_ascii() || cleaned.len() != 15 {
213 return None;
214 }
215
216 let dept = &cleaned[5..7];
217 let numeric_body = match dept {
218 "2A" => format!("{}19{}", &cleaned[0..5], &cleaned[7..13]),
219 "2B" => format!("{}18{}", &cleaned[0..5], &cleaned[7..13]),
220 _ => cleaned[0..13].to_string(),
221 };
222
223 if !numeric_body.chars().all(|c| c.is_ascii_digit()) {
224 return None;
225 }
226 let key_str = &cleaned[13..15];
227 if !key_str.chars().all(|c| c.is_ascii_digit()) {
228 return None;
229 }
230
231 let n: u64 = numeric_body.parse().ok()?;
232 let key: u64 = key_str.parse().ok()?;
233
234 if 97 - (n % 97) == key {
235 Some(cleaned)
236 } else {
237 None
238 }
239}
240
241/// Parse a España (Spain) TSI (*Tarjeta Sanitaria Individual*) / CIP-SNS identifier.
242///
243/// Spain's healthcare identification is fragmented across 17 autonomous
244/// communities, each of which issues its own TSI card with a region-specific
245/// format. The national-level *Código de Identificación Workeral del Sistema
246/// Nacional de Salud* (CIP-SNS) provides a uniform 16-character code with
247/// the canonical structure `LLLLDDDDDDXXXXXX` (4 letters + 6 digits + 6
248/// alphanumerics), but regional formats are also encountered in practice.
249///
250/// To accept the full population of legitimate identifiers without
251/// privileging any region, this parser is **format-only** and lenient:
252///
253/// 1. Whitespace and ASCII hyphens are stripped.
254/// 2. Letters are uppercased.
255/// 3. The remaining string must contain only ASCII alphanumerics.
256/// 4. The length must be in `10..=20`.
257///
258/// No check-digit calculation is performed because the schemes vary by
259/// community. A consumer that needs stronger validation should layer a
260/// community-specific check on top of this canonical form.
261///
262/// # Examples
263///
264/// ```
265/// use worker_matcher::identifiers::parse_es_tsi;
266///
267/// // 16-character CIP-SNS national code:
268/// assert_eq!(
269/// parse_es_tsi("ABCD123456XY1234"),
270/// Some("ABCD123456XY1234".to_string()),
271/// );
272///
273/// // Whitespace and hyphens are stripped, letters uppercased:
274/// assert_eq!(
275/// parse_es_tsi("abcd 123 456-xy1234"),
276/// Some("ABCD123456XY1234".to_string()),
277/// );
278///
279/// // Too short, too long, or containing non-alphanumerics rejects:
280/// assert_eq!(parse_es_tsi("ABC123"), None); // 6 chars
281/// assert_eq!(parse_es_tsi("ABCDEF123456XY12345678"), None); // 22 chars
282/// assert_eq!(parse_es_tsi("ABC@123!XYZ"), None); // bad chars
283/// ```
284pub fn parse_es_tsi(s: &str) -> Option<String> {
285 let cleaned: String = s
286 .chars()
287 .filter(|c| !c.is_whitespace() && *c != '-')
288 .collect::<String>()
289 .to_uppercase();
290
291 if !cleaned.is_ascii() {
292 return None;
293 }
294 if !cleaned.chars().all(|c| c.is_ascii_alphanumeric()) {
295 return None;
296 }
297 if !(10..=20).contains(&cleaned.len()) {
298 return None;
299 }
300 Some(cleaned)
301}
302
303/// Parse an Éire (Ireland) IHI (Individual Health Identifier).
304///
305/// Under the Health Identifiers Act 2014, every individual receiving
306/// healthcare in Ireland is assigned a 7-digit IHI by the Health Identifiers
307/// Service. The IHI is the unique national healthcare identifier in the
308/// Republic of Ireland.
309///
310/// Parsing rules:
311///
312/// 1. All non-digit characters are stripped (spaces, hyphens, etc.).
313/// 2. The remaining string must contain exactly 7 ASCII digits.
314///
315/// No check-digit algorithm is enforced (none is publicly specified). The
316/// canonical form is the 7-digit string.
317///
318/// # Examples
319///
320/// ```
321/// use worker_matcher::identifiers::parse_ie_ihi;
322///
323/// assert_eq!(parse_ie_ihi("1234567"), Some("1234567".to_string()));
324/// assert_eq!(parse_ie_ihi("123 4567"), Some("1234567".to_string()));
325/// assert_eq!(parse_ie_ihi("123-45-67"), Some("1234567".to_string()));
326///
327/// assert_eq!(parse_ie_ihi("12345"), None); // too short
328/// assert_eq!(parse_ie_ihi("12345678"), None); // too long
329/// assert_eq!(parse_ie_ihi("ABCDEFG"), None); // not digits
330/// ```
331pub fn parse_ie_ihi(s: &str) -> Option<String> {
332 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
333 if digits.len() == 7 {
334 Some(digits)
335 } else {
336 None
337 }
338}
339
340/// Parse a United Kingdom Northern Ireland H&C (Health and Care) Number.
341///
342/// The H&C Number is Northern Ireland's national healthcare identifier,
343/// issued by HSC (Health and Social Care). Structurally it is a 10-digit
344/// number with a Modulus-11 check digit — the same algorithm used by the
345/// UK NHS Number.
346///
347/// This parser delegates to the same logic as [`parse_uk_nhs_number`]: it
348/// accepts either the compact 10-digit form or the spaced
349/// `"XXX XXX XXXX"` form and returns the canonical 10-digit string.
350///
351/// The two parsers are intentionally separate so that callers track *which*
352/// scheme an identifier belongs to: a number that parses successfully as
353/// both an NHS Number and an H&C Number still refers to two distinct people
354/// in two distinct registries.
355///
356/// # Examples
357///
358/// ```
359/// use worker_matcher::identifiers::parse_uk_hc_number;
360///
361/// assert_eq!(parse_uk_hc_number("9434765919"), Some("9434765919".to_string()));
362/// assert_eq!(parse_uk_hc_number("943 476 5919"), Some("9434765919".to_string()));
363/// assert_eq!(parse_uk_hc_number("not-a-number"), None);
364/// ```
365pub fn parse_uk_hc_number(s: &str) -> Option<String> {
366 parse_uk_nhs_number(s)
367}
368
369/// Parse a United States Social Security Number (SSN).
370///
371/// The SSN is the United States' de-facto national identifier — a 9-digit
372/// number assigned by the Social Security Administration, conventionally
373/// formatted as `"AAA-GG-SSSS"`:
374///
375/// ```text
376/// AAA - GG - SSSS
377/// │ │ └──── Serial Number (4 digits, 0001..=9999)
378/// │ └───────── Group Number (2 digits, 01..=99)
379/// └──────────────── Area Number (3 digits, 001..=665, 667..=899)
380/// ```
381///
382/// Parsing rules:
383///
384/// 1. Keep only ASCII digits (strip whitespace, hyphens, periods,
385/// parentheses, …).
386/// 2. Reject unless the result has exactly 9 digits.
387/// 3. Reject structurally-impossible area numbers (`000`, `666`, and
388/// `900..=999`). These have never been assigned by SSA.
389/// 4. Reject group `00`.
390/// 5. Reject serial `0000`.
391///
392/// Since SSA introduced randomised assignment in June 2011, the area
393/// number no longer encodes the state of issuance, so no geographic
394/// validation is attempted. The canonical form is the 9-digit compact
395/// string `"AAAGGSSSS"`.
396///
397/// # Examples
398///
399/// Three textual layouts of the same SSN canonicalise identically:
400///
401/// ```
402/// use worker_matcher::identifiers::parse_us_ssn;
403///
404/// assert_eq!(parse_us_ssn("123-45-6789"), Some("123456789".to_string()));
405/// assert_eq!(parse_us_ssn("123 45 6789"), Some("123456789".to_string()));
406/// assert_eq!(parse_us_ssn("123456789"), Some("123456789".to_string()));
407/// ```
408///
409/// Structurally-invalid values are rejected:
410///
411/// ```
412/// # use worker_matcher::identifiers::parse_us_ssn;
413/// assert_eq!(parse_us_ssn("000-12-3456"), None); // area 000 never issued
414/// assert_eq!(parse_us_ssn("666-12-3456"), None); // area 666 never issued
415/// assert_eq!(parse_us_ssn("900-12-3456"), None); // area 900..=999 never issued
416/// assert_eq!(parse_us_ssn("123-00-4567"), None); // group 00 invalid
417/// assert_eq!(parse_us_ssn("123-45-0000"), None); // serial 0000 invalid
418/// assert_eq!(parse_us_ssn("12345"), None); // too short
419/// assert_eq!(parse_us_ssn("ABCDEFGHI"), None); // not digits
420/// assert_eq!(parse_us_ssn(""), None);
421/// ```
422pub fn parse_us_ssn(s: &str) -> Option<String> {
423 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
424 if digits.len() != 9 {
425 return None;
426 }
427 let area: u32 = digits[0..3].parse().ok()?;
428 let group: u32 = digits[3..5].parse().ok()?;
429 let serial: u32 = digits[5..9].parse().ok()?;
430 if area == 0 || area == 666 || area >= 900 {
431 return None;
432 }
433 if group == 0 {
434 return None;
435 }
436 if serial == 0 {
437 return None;
438 }
439 Some(digits)
440}
441
442/// Parse a Germany KVNR (*Krankenversichertennummer*).
443///
444/// The KVNR is the lifelong health-insurance number printed on the
445/// German electronic health card (*elektronische Gesundheitskarte*).
446/// Structure: 10 characters total, one uppercase letter followed by 9
447/// digits. The final digit is a Mod-10 check digit.
448///
449/// Check-digit algorithm:
450///
451/// 1. Map the leading letter to a two-digit ordinal (`A=01`, `B=02`,
452/// …, `Z=26`).
453/// 2. Concatenate that 2-digit value with positions 2..=9 of the KVNR
454/// (the 8 digits before the check digit) → a 10-digit string.
455/// 3. Multiply each of those 10 digits by alternating weights
456/// `1, 2, 1, 2, 1, 2, 1, 2, 1, 2`.
457/// 4. For products `≥ 10`, replace with the digit sum (max product is
458/// `9 × 2 = 18`, so subtract 9 to digit-sum).
459/// 5. Sum all results; the check digit is `sum mod 10`.
460///
461/// Whitespace is stripped before parsing. The canonical form is the
462/// 10-character uppercase string.
463///
464/// # Examples
465///
466/// ```
467/// use worker_matcher::identifiers::parse_de_kvnr;
468///
469/// // Constructed valid KVNR (A=01; alternating Mod-10 yields check digit 0).
470/// assert_eq!(parse_de_kvnr("A123456780"), Some("A123456780".to_string()));
471/// assert_eq!(parse_de_kvnr("a123456780"), Some("A123456780".to_string())); // lowercase letter accepted
472///
473/// // Wrong check digit:
474/// assert_eq!(parse_de_kvnr("A123456789"), None);
475///
476/// // Wrong length / shape:
477/// assert_eq!(parse_de_kvnr("1234567890"), None); // no letter
478/// assert_eq!(parse_de_kvnr("A12345"), None);
479/// assert_eq!(parse_de_kvnr(""), None);
480/// ```
481pub fn parse_de_kvnr(s: &str) -> Option<String> {
482 let cleaned: String = s
483 .chars()
484 .filter(|c| !c.is_whitespace())
485 .collect::<String>()
486 .to_uppercase();
487 if !cleaned.is_ascii() || cleaned.len() != 10 {
488 return None;
489 }
490 let mut chars = cleaned.chars();
491 let first = chars.next()?;
492 if !first.is_ascii_alphabetic() {
493 return None;
494 }
495 let digit_chars: Vec<char> = chars.collect();
496 if !digit_chars.iter().all(|c| c.is_ascii_digit()) {
497 return None;
498 }
499 let letter_ord = (first as u32) - ('A' as u32) + 1;
500 let mut combined: Vec<u32> = vec![letter_ord / 10, letter_ord % 10];
501 for c in &digit_chars[..8] {
502 combined.push(c.to_digit(10)?);
503 }
504 let mut total: u32 = 0;
505 for (i, d) in combined.iter().enumerate() {
506 let weight = if i % 2 == 0 { 1 } else { 2 };
507 let product = d * weight;
508 total += if product >= 10 { product - 9 } else { product };
509 }
510 let expected = digit_chars[8].to_digit(10)?;
511 if total % 10 == expected {
512 Some(cleaned)
513 } else {
514 None
515 }
516}
517
518/// Per-position lookup table for the Italy *Codice Fiscale* check
519/// character.
520///
521/// "Odd" positions are the 1st, 3rd, 5th, …, 15th characters
522/// (1-indexed); they map per a specific table that intentionally
523/// scatters values so single-character typos are likely to shift the
524/// resulting check character.
525fn cf_odd_value(c: char) -> Option<u32> {
526 Some(match c {
527 '0' | 'A' => 1,
528 '1' | 'B' => 0,
529 '2' | 'C' => 5,
530 '3' | 'D' => 7,
531 '4' | 'E' => 9,
532 '5' | 'F' => 13,
533 '6' | 'G' => 15,
534 '7' | 'H' => 17,
535 '8' | 'I' => 19,
536 '9' | 'J' => 21,
537 'K' => 2,
538 'L' => 4,
539 'M' => 18,
540 'N' => 20,
541 'O' => 11,
542 'P' => 3,
543 'Q' => 6,
544 'R' => 8,
545 'S' => 12,
546 'T' => 14,
547 'U' => 16,
548 'V' => 10,
549 'W' => 22,
550 'X' => 25,
551 'Y' => 24,
552 'Z' => 23,
553 _ => return None,
554 })
555}
556
557/// "Even" positions (2nd, 4th, …, 14th, 1-indexed) for the Italy
558/// *Codice Fiscale* check character. Numeric values map to their digit
559/// value; letters map to `A=0`, `B=1`, …, `Z=25`.
560fn cf_even_value(c: char) -> Option<u32> {
561 Some(match c {
562 '0' | 'A' => 0,
563 '1' | 'B' => 1,
564 '2' | 'C' => 2,
565 '3' | 'D' => 3,
566 '4' | 'E' => 4,
567 '5' | 'F' => 5,
568 '6' | 'G' => 6,
569 '7' | 'H' => 7,
570 '8' | 'I' => 8,
571 '9' | 'J' => 9,
572 'K' => 10,
573 'L' => 11,
574 'M' => 12,
575 'N' => 13,
576 'O' => 14,
577 'P' => 15,
578 'Q' => 16,
579 'R' => 17,
580 'S' => 18,
581 'T' => 19,
582 'U' => 20,
583 'V' => 21,
584 'W' => 22,
585 'X' => 23,
586 'Y' => 24,
587 'Z' => 25,
588 _ => return None,
589 })
590}
591
592/// Parse an Italy *Codice Fiscale* (CF).
593///
594/// The CF is a 16-character alphanumeric identifier issued by the
595/// Italian tax authority and used as the de-facto national healthcare
596/// identifier. It encodes a coded form of the holder's name, date of
597/// birth, sex, and commune of birth, followed by a Mod-26 check
598/// character.
599///
600/// Check-character algorithm:
601///
602/// 1. For each of the first 15 characters, compute a numeric value
603/// using two lookup tables — "odd" positions (1, 3, 5, …, 15;
604/// 1-indexed) use the scattered table; "even" positions (2, 4, …,
605/// 14) map digits and letters to their natural value.
606/// 2. Sum the 15 values, take mod 26.
607/// 3. Map `0..=25` to `A..=Z`. The result MUST equal the 16th
608/// character.
609///
610/// Whitespace is stripped and letters are uppercased before parsing.
611/// The canonical form is the 16-character uppercase string.
612///
613/// # Examples
614///
615/// ```
616/// use worker_matcher::identifiers::parse_it_cf;
617///
618/// // Synthetic CF with verified check character (sum 122, mod 26 = 18, 18→'S').
619/// assert_eq!(
620/// parse_it_cf("RSSMRA85T10A562S"),
621/// Some("RSSMRA85T10A562S".to_string()),
622/// );
623/// // Lowercase and whitespace tolerated:
624/// assert_eq!(
625/// parse_it_cf("rss mra 85t 10a 562s"),
626/// Some("RSSMRA85T10A562S".to_string()),
627/// );
628///
629/// // Wrong check character:
630/// assert_eq!(parse_it_cf("RSSMRA85T10A562X"), None);
631///
632/// // Wrong length:
633/// assert_eq!(parse_it_cf("RSSMRA85T10A562"), None);
634/// assert_eq!(parse_it_cf("RSSMRA85T10A562SS"), None);
635/// // Non-alphanumeric content:
636/// assert_eq!(parse_it_cf("RSSMRA85T10A562!"), None);
637/// assert_eq!(parse_it_cf(""), None);
638/// ```
639pub fn parse_it_cf(s: &str) -> Option<String> {
640 let cleaned: String = s
641 .chars()
642 .filter(|c| !c.is_whitespace())
643 .collect::<String>()
644 .to_uppercase();
645 if !cleaned.is_ascii() || cleaned.len() != 16 {
646 return None;
647 }
648 if !cleaned.chars().all(|c| c.is_ascii_alphanumeric()) {
649 return None;
650 }
651 let chars: Vec<char> = cleaned.chars().collect();
652 let mut total: u32 = 0;
653 for (i, c) in chars.iter().take(15).enumerate() {
654 // 1-indexed position parity: index 0 is position 1 (odd).
655 let value = if i % 2 == 0 {
656 cf_odd_value(*c)?
657 } else {
658 cf_even_value(*c)?
659 };
660 total += value;
661 }
662 let expected_check = (b'A' + (total % 26) as u8) as char;
663 if chars[15] == expected_check {
664 Some(cleaned)
665 } else {
666 None
667 }
668}
669
670/// Parse a Netherlands BSN (*Burgerservicenummer*).
671///
672/// The BSN is a 9-digit citizen-service number used by all Dutch
673/// authorities, including healthcare providers. It carries an
674/// "11-test" check rule originally derived from the bank account
675/// number validation.
676///
677/// Check rule (the "11-test"):
678///
679/// `9·d₁ + 8·d₂ + 7·d₃ + 6·d₄ + 5·d₅ + 4·d₆ + 3·d₇ + 2·d₈ − d₉ ≡ 0 (mod 11)`
680///
681/// Non-digit characters are stripped before validation (so spaces or
682/// hyphens used for readability are tolerated). The all-zero string
683/// `000000000` is rejected even though it satisfies the arithmetic.
684/// The canonical form is the 9-digit compact string.
685///
686/// # Examples
687///
688/// ```
689/// use worker_matcher::identifiers::parse_nl_bsn;
690///
691/// // 111222333: 9·1 + 8·1 + 7·1 + 6·2 + 5·2 + 4·2 + 3·3 + 2·3 − 3 = 66; 66 mod 11 = 0.
692/// assert_eq!(parse_nl_bsn("111222333"), Some("111222333".to_string()));
693/// assert_eq!(parse_nl_bsn("111 222 333"), Some("111222333".to_string()));
694///
695/// // Wrong check (final digit changed):
696/// assert_eq!(parse_nl_bsn("111222334"), None);
697///
698/// // Wrong length, non-digits, all-zeros, empty:
699/// assert_eq!(parse_nl_bsn("12345"), None);
700/// assert_eq!(parse_nl_bsn("ABCDEFGHI"), None);
701/// assert_eq!(parse_nl_bsn("000000000"), None);
702/// assert_eq!(parse_nl_bsn(""), None);
703/// ```
704pub fn parse_nl_bsn(s: &str) -> Option<String> {
705 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
706 if digits.len() != 9 {
707 return None;
708 }
709 if digits.chars().all(|c| c == '0') {
710 return None;
711 }
712 let weights: [i32; 9] = [9, 8, 7, 6, 5, 4, 3, 2, -1];
713 let mut sum: i32 = 0;
714 for (i, c) in digits.chars().enumerate() {
715 sum += (c.to_digit(10)? as i32) * weights[i];
716 }
717 if sum % 11 == 0 { Some(digits) } else { None }
718}
719
720/// Parse a Sweden *Workernummer*.
721///
722/// The Swedish workeral identity number is the national identifier
723/// used for taxation, healthcare, banking, and similar purposes. It
724/// comes in two textual layouts:
725///
726/// - 10-digit form: `YYMMDDNNNC` (or with a `-` / `+` separator
727/// between the date and the serial, e.g. `460324-3850`). The `+`
728/// separator indicates the holder is over 100 years old.
729/// - 12-digit form: `YYYYMMDDNNNC` (or `19460324-3850`).
730///
731/// `Y`/`M`/`D` are the birth-date digits, `NNN` is a 3-digit serial
732/// (odd = male, even = female under the historical convention), and
733/// `C` is the Luhn check digit computed over the 10 digits of the
734/// 10-digit form.
735///
736/// Non-digit characters are stripped before validation. The Luhn
737/// check uses left-to-right weights `2, 1, 2, 1, 2, 1, 2, 1, 2, 1`;
738/// products `≥ 10` are reduced by digit-sum; the total mod 10 must be
739/// `0`.
740///
741/// The canonical form preserves the input length: 10-digit input
742/// returns a 10-character string; 12-digit input returns a 12-character
743/// string. Records using mixed layouts will not match deterministically
744/// on this field, but they will still produce the correct Luhn
745/// validation.
746///
747/// # Examples
748///
749/// ```
750/// use worker_matcher::identifiers::parse_se_workernummer;
751///
752/// // Synthetic 10-digit workernummer with verified Luhn (sum 40, mod 10 = 0).
753/// assert_eq!(
754/// parse_se_workernummer("4603243850"),
755/// Some("4603243850".to_string()),
756/// );
757/// assert_eq!(
758/// parse_se_workernummer("460324-3850"),
759/// Some("4603243850".to_string()),
760/// );
761///
762/// // 12-digit form canonicalises with the century preserved.
763/// assert_eq!(
764/// parse_se_workernummer("19460324-3850"),
765/// Some("194603243850".to_string()),
766/// );
767///
768/// // Wrong Luhn:
769/// assert_eq!(parse_se_workernummer("4603243851"), None);
770///
771/// // Wrong length, non-digits, empty:
772/// assert_eq!(parse_se_workernummer("12345"), None);
773/// assert_eq!(parse_se_workernummer("ABCDEFGHIJ"), None);
774/// assert_eq!(parse_se_workernummer(""), None);
775/// ```
776pub fn parse_se_workernummer(s: &str) -> Option<String> {
777 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
778 let luhn_digits: &str = match digits.len() {
779 10 => &digits,
780 12 => &digits[2..],
781 _ => return None,
782 };
783 let mut sum: u32 = 0;
784 for (i, c) in luhn_digits.chars().enumerate() {
785 let d = c.to_digit(10)?;
786 let weight = if i % 2 == 0 { 2 } else { 1 };
787 let product = d * weight;
788 sum += if product >= 10 { product - 9 } else { product };
789 }
790 if sum.is_multiple_of(10) {
791 Some(digits)
792 } else {
793 None
794 }
795}
796
797/// Parse an Australia IHI (Individual Healthcare Identifier).
798///
799/// The IHI is the unique 16-digit identifier issued by the Healthcare
800/// Identifiers Service (HI Service) of the Australian Digital Health
801/// Agency. It conforms to ISO/IEC 7812-1 with a Luhn check digit.
802///
803/// Non-digit characters are stripped before validation. The Luhn
804/// check uses left-to-right weights `2, 1, 2, 1, …` over all 16
805/// digits (the rightmost digit is the check); products `≥ 10` are
806/// reduced by digit-sum; the total mod 10 must be `0`. The structural
807/// convention that real IHIs begin with `800360` is **not** enforced
808/// here so test and migration data with other prefixes parse cleanly.
809///
810/// # Examples
811///
812/// ```
813/// use worker_matcher::identifiers::parse_au_ihi;
814///
815/// // Synthetic 16-digit IHI with verified Luhn.
816/// assert_eq!(
817/// parse_au_ihi("8003601234567894"),
818/// Some("8003601234567894".to_string()),
819/// );
820/// assert_eq!(
821/// parse_au_ihi("8003 6012 3456 7894"),
822/// Some("8003601234567894".to_string()),
823/// );
824///
825/// // Wrong Luhn / wrong length / non-digits:
826/// assert_eq!(parse_au_ihi("8003601234567890"), None);
827/// assert_eq!(parse_au_ihi("12345"), None);
828/// assert_eq!(parse_au_ihi("ABCDEFGHIJKLMNOP"), None);
829/// assert_eq!(parse_au_ihi(""), None);
830/// ```
831pub fn parse_au_ihi(s: &str) -> Option<String> {
832 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
833 if digits.len() != 16 {
834 return None;
835 }
836 let mut sum: u32 = 0;
837 for (i, c) in digits.chars().enumerate() {
838 let d = c.to_digit(10)?;
839 let weight = if i % 2 == 0 { 2 } else { 1 };
840 let product = d * weight;
841 sum += if product >= 10 { product - 9 } else { product };
842 }
843 if sum.is_multiple_of(10) {
844 Some(digits)
845 } else {
846 None
847 }
848}
849
850/// Parse a Scotland CHI (Community Health Index) Number.
851///
852/// The CHI Number is the unique worker identifier used by NHS
853/// Scotland. Structure: 10 digits formatted `DDMMYYSSSC`, where
854/// `DDMMYY` is the holder's date of birth, `SSS` is a 3-digit
855/// sequence with the third digit encoding sex (odd = male, even =
856/// female), and `C` is a Mod-11 check digit computed in the same
857/// fashion as the UK NHS Number.
858///
859/// Check rule (Mod-11):
860///
861/// 1. Multiply each of the first 9 digits by the weights
862/// `10, 9, 8, 7, 6, 5, 4, 3, 2`.
863/// 2. Sum, take mod 11.
864/// 3. The check digit is `(11 − (sum mod 11)) mod 11`. A computed
865/// check of `10` indicates an invalid identifier and is rejected.
866///
867/// Non-digit characters are stripped before validation. The canonical
868/// form is the 10-digit compact string. Although the NHS Number and
869/// the CHI Number share the same Mod-11 algorithm, the two are
870/// **scheme-local** in this crate and never cross-match (per spec
871/// FR-13 / §12.1).
872///
873/// # Examples
874///
875/// ```
876/// use worker_matcher::identifiers::parse_uk_chi_number;
877///
878/// // Synthetic CHI with verified Mod-11 (sum 74, check = 3).
879/// assert_eq!(
880/// parse_uk_chi_number("0101701233"),
881/// Some("0101701233".to_string()),
882/// );
883/// assert_eq!(
884/// parse_uk_chi_number("010 170 1233"),
885/// Some("0101701233".to_string()),
886/// );
887///
888/// // Wrong check / length / non-digits:
889/// assert_eq!(parse_uk_chi_number("0101701234"), None);
890/// assert_eq!(parse_uk_chi_number("12345"), None);
891/// assert_eq!(parse_uk_chi_number("ABCDEFGHIJ"), None);
892/// assert_eq!(parse_uk_chi_number(""), None);
893/// ```
894pub fn parse_uk_chi_number(s: &str) -> Option<String> {
895 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
896 if digits.len() != 10 {
897 return None;
898 }
899 let chars: Vec<u32> = digits
900 .chars()
901 .map(|c| c.to_digit(10).expect("filtered to digits"))
902 .collect();
903 let weights = [10u32, 9, 8, 7, 6, 5, 4, 3, 2];
904 let sum: u32 = chars
905 .iter()
906 .take(9)
907 .zip(weights.iter())
908 .map(|(d, w)| d * w)
909 .sum();
910 let check = (11 - (sum % 11)) % 11;
911 if check == 10 {
912 return None;
913 }
914 if check == chars[9] {
915 Some(digits)
916 } else {
917 None
918 }
919}
920
921// ----------------------------------------------------------------------------
922// Additional national workeral identifiers (T-27).
923//
924// Each parser canonicalises whitespace + (where applicable) case, and verifies
925// the scheme's check digit / check character. Parsers return Option<String>;
926// `Some(canonical)` is suitable for byte-equality comparison.
927// ----------------------------------------------------------------------------
928
929/// Parse a Belgium *Rijksregisternummer* (National Number).
930///
931/// 11 digits: `YYMMDD` + 3-digit serial + 2-digit Mod-97 check.
932/// Pre-2000 births: check = `97 − (first-9-digits mod 97)`.
933/// 2000-and-later births: a `"2"` is prepended before the modulo step.
934/// The parser tries both and accepts either.
935///
936/// ```
937/// use worker_matcher::identifiers::parse_be_nn;
938/// assert_eq!(parse_be_nn("80010100107"), Some("80010100107".to_string()));
939/// assert_eq!(parse_be_nn("80.01.01-001.07"), Some("80010100107".to_string()));
940/// assert_eq!(parse_be_nn("80010100100"), None); // wrong check
941/// assert_eq!(parse_be_nn("12345"), None); // wrong length
942/// ```
943pub fn parse_be_nn(s: &str) -> Option<String> {
944 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
945 if digits.len() != 11 {
946 return None;
947 }
948 let body: u64 = digits[..9].parse().ok()?;
949 let check: u64 = digits[9..11].parse().ok()?;
950 let pre2000 = 97 - body % 97;
951 let post2000_body: u64 = format!("2{}", &digits[..9]).parse().ok()?;
952 let post2000 = 97 - post2000_body % 97;
953 if check == pre2000 || check == post2000 {
954 Some(digits)
955 } else {
956 None
957 }
958}
959
960/// Parse a Bulgaria EGN (*Edinen grazhdanski nomer*).
961///
962/// 10 digits: `YYMMDD` (with month-offset for century) + 3-digit area/serial
963/// + 1 check digit. Check uses weights `[2,4,8,5,10,9,7,3,6]` mod 11 (10 → 0).
964///
965/// ```
966/// use worker_matcher::identifiers::parse_bg_egn;
967/// assert_eq!(parse_bg_egn("8001010013"), Some("8001010013".to_string()));
968/// assert_eq!(parse_bg_egn("8001010014"), None);
969/// assert_eq!(parse_bg_egn(""), None);
970/// ```
971pub fn parse_bg_egn(s: &str) -> Option<String> {
972 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
973 if digits.len() != 10 {
974 return None;
975 }
976 let weights: [u32; 9] = [2, 4, 8, 5, 10, 9, 7, 3, 6];
977 let mut sum: u32 = 0;
978 for (i, c) in digits.chars().take(9).enumerate() {
979 sum += c.to_digit(10)? * weights[i];
980 }
981 let expected = if sum % 11 == 10 { 0 } else { sum % 11 };
982 if digits.chars().nth(9)?.to_digit(10)? == expected {
983 Some(digits)
984 } else {
985 None
986 }
987}
988
989/// Parse a Czech Republic *Rodné číslo*.
990///
991/// 9 or 10 digits. The 10-digit form (post-1953) is divisible by 11 (with
992/// the edge case that mod-11 = 10 collapses to a trailing 0; the resulting
993/// 10-digit number may NOT be divisible by 11). The 9-digit form (pre-1954)
994/// is accepted as-is.
995///
996/// ```
997/// use worker_matcher::identifiers::parse_cz_rc;
998/// assert_eq!(parse_cz_rc("8001150014"), Some("8001150014".to_string()));
999/// assert_eq!(parse_cz_rc("800115001"), Some("800115001".to_string())); // 9-digit pre-1954
1000/// assert_eq!(parse_cz_rc("8001150015"), None);
1001/// ```
1002pub fn parse_cz_rc(s: &str) -> Option<String> {
1003 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1004 match digits.len() {
1005 9 => Some(digits),
1006 10 => {
1007 let n: u64 = digits.parse().ok()?;
1008 // Standard rule: 10-digit RČ is divisible by 11. Edge case:
1009 // when first-9-digit mod 11 = 10, the trailing 0 is used and
1010 // the full number's mod 11 is 10, not 0.
1011 let head: u64 = digits[..9].parse().ok()?;
1012 let tail = digits.chars().last()?.to_digit(10)?;
1013 if n.is_multiple_of(11) || (head % 11 == 10 && tail == 0) {
1014 Some(digits)
1015 } else {
1016 None
1017 }
1018 }
1019 _ => None,
1020 }
1021}
1022
1023/// Parse a Denmark CPR (*Centrale Workerregister*).
1024///
1025/// 10 digits `DDMMYYNNNN`. Format-only validation; the historical Modulus-11
1026/// check was abandoned in 2007.
1027///
1028/// ```
1029/// use worker_matcher::identifiers::parse_dk_cpr;
1030/// assert_eq!(parse_dk_cpr("1501801234"), Some("1501801234".to_string()));
1031/// assert_eq!(parse_dk_cpr("150180-1234"), Some("1501801234".to_string()));
1032/// assert_eq!(parse_dk_cpr("12345"), None);
1033/// ```
1034pub fn parse_dk_cpr(s: &str) -> Option<String> {
1035 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1036 if digits.len() == 10 {
1037 Some(digits)
1038 } else {
1039 None
1040 }
1041}
1042
1043/// Cascading Mod-11 check used by Estonia and Lithuania.
1044fn baltic_cascade_check(digits: &str) -> Option<u32> {
1045 const PASS1: [u32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 1];
1046 const PASS2: [u32; 10] = [3, 4, 5, 6, 7, 8, 9, 1, 2, 3];
1047 let body: Vec<u32> = digits
1048 .chars()
1049 .take(10)
1050 .filter_map(|c| c.to_digit(10))
1051 .collect();
1052 if body.len() != 10 {
1053 return None;
1054 }
1055 let s1: u32 = body.iter().zip(PASS1.iter()).map(|(d, w)| d * w).sum();
1056 let r1 = s1 % 11;
1057 if r1 < 10 {
1058 return Some(r1);
1059 }
1060 let s2: u32 = body.iter().zip(PASS2.iter()).map(|(d, w)| d * w).sum();
1061 let r2 = s2 % 11;
1062 if r2 < 10 { Some(r2) } else { Some(0) }
1063}
1064
1065/// Parse an Estonia *Isikukood* (Workeral Identification Code).
1066///
1067/// 11 digits `GYYMMDDNNNC`. Check digit uses a cascading Mod-11 algorithm.
1068///
1069/// ```
1070/// use worker_matcher::identifiers::parse_ee_ik;
1071/// assert_eq!(parse_ee_ik("48001150011"), Some("48001150011".to_string()));
1072/// assert_eq!(parse_ee_ik("48001150012"), None);
1073/// ```
1074pub fn parse_ee_ik(s: &str) -> Option<String> {
1075 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1076 if digits.len() != 11 {
1077 return None;
1078 }
1079 let expected = baltic_cascade_check(&digits[..10])?;
1080 if digits.chars().nth(10)?.to_digit(10)? == expected {
1081 Some(digits)
1082 } else {
1083 None
1084 }
1085}
1086
1087/// Parse a Spain DNI / NIE.
1088///
1089/// 8 digits (NIE: prefixed `X`/`Y`/`Z`) + 1 control letter. The letter is
1090/// `"TRWAGMYFPDXBNJZSQVHLCKE"` indexed by `number mod 23`. NIE prefixes map
1091/// to leading digits: `X→0`, `Y→1`, `Z→2`.
1092///
1093/// ```
1094/// use worker_matcher::identifiers::parse_es_dni;
1095/// assert_eq!(parse_es_dni("12345678Z"), Some("12345678Z".to_string()));
1096/// assert_eq!(parse_es_dni("12345678-Z"), Some("12345678Z".to_string()));
1097/// assert_eq!(parse_es_dni("12345678A"), None); // wrong letter
1098/// ```
1099pub fn parse_es_dni(s: &str) -> Option<String> {
1100 let cleaned: String = s
1101 .chars()
1102 .filter(|c| c.is_ascii_alphanumeric())
1103 .collect::<String>()
1104 .to_uppercase();
1105 if cleaned.is_empty() {
1106 return None;
1107 }
1108 let last = cleaned.chars().last()?;
1109 if !last.is_ascii_alphabetic() {
1110 return None;
1111 }
1112 let body = &cleaned[..cleaned.len() - 1];
1113 let n: u64 = match body.chars().next()? {
1114 'X' => format!("0{}", &body[1..]).parse().ok()?,
1115 'Y' => format!("1{}", &body[1..]).parse().ok()?,
1116 'Z' => format!("2{}", &body[1..]).parse().ok()?,
1117 d if d.is_ascii_digit() => body.parse().ok()?,
1118 _ => return None,
1119 };
1120 const LETTERS: &[u8; 23] = b"TRWAGMYFPDXBNJZSQVHLCKE";
1121 let expected = LETTERS[(n % 23) as usize] as char;
1122 if last == expected {
1123 Some(cleaned)
1124 } else {
1125 None
1126 }
1127}
1128
1129/// Parse a Finland HETU (*Henkilötunnus*).
1130///
1131/// 11 characters `DDMMYYCZZZK` where `C` is a century sign (`-`/`+`/`A` and
1132/// later additions) and `K` is a check character from
1133/// `"0123456789ABCDEFHJKLMNPRSTUVWXY"` indexed by `(DDMMYYZZZ as 9-digit
1134/// number) mod 31`.
1135///
1136/// ```
1137/// use worker_matcher::identifiers::parse_fi_hetu;
1138/// assert_eq!(parse_fi_hetu("150180-999B"), Some("150180-999B".to_string()));
1139/// assert_eq!(parse_fi_hetu("150180-999C"), None);
1140/// ```
1141pub fn parse_fi_hetu(s: &str) -> Option<String> {
1142 let cleaned: String = s
1143 .chars()
1144 .filter(|c| !c.is_whitespace())
1145 .collect::<String>()
1146 .to_uppercase();
1147 if !cleaned.is_ascii() || cleaned.len() != 11 {
1148 return None;
1149 }
1150 let date: &str = &cleaned[..6];
1151 let sign = cleaned.chars().nth(6)?;
1152 let serial: &str = &cleaned[7..10];
1153 let check = cleaned.chars().nth(10)?;
1154 if !date.chars().all(|c| c.is_ascii_digit()) {
1155 return None;
1156 }
1157 if !serial.chars().all(|c| c.is_ascii_digit()) {
1158 return None;
1159 }
1160 // Accept the historically-known and FICORA-extended signs.
1161 if !matches!(
1162 sign,
1163 '-' | '+' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'X' | 'Y'
1164 ) {
1165 return None;
1166 }
1167 let n: u64 = format!("{}{}", date, serial).parse().ok()?;
1168 const TABLE: &[u8; 31] = b"0123456789ABCDEFHJKLMNPRSTUVWXY";
1169 let expected = TABLE[(n % 31) as usize] as char;
1170 if check == expected {
1171 Some(cleaned)
1172 } else {
1173 None
1174 }
1175}
1176
1177/// Parse a Croatia OIB (*Osobni identifikacijski broj*).
1178///
1179/// 11 digits. Check digit via ISO 7064 MOD 11,10.
1180///
1181/// ```
1182/// use worker_matcher::identifiers::parse_hr_oib;
1183/// assert_eq!(parse_hr_oib("12345678903"), Some("12345678903".to_string()));
1184/// assert_eq!(parse_hr_oib("12345678901"), None);
1185/// ```
1186pub fn parse_hr_oib(s: &str) -> Option<String> {
1187 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1188 if digits.len() != 11 {
1189 return None;
1190 }
1191 let mut acc: u32 = 10;
1192 for c in digits.chars().take(10) {
1193 let d = c.to_digit(10)?;
1194 let mut x = (d + acc) % 10;
1195 if x == 0 {
1196 x = 10;
1197 }
1198 acc = (x * 2) % 11;
1199 }
1200 let expected = (11 - acc) % 10;
1201 if digits.chars().nth(10)?.to_digit(10)? == expected {
1202 Some(digits)
1203 } else {
1204 None
1205 }
1206}
1207
1208/// Parse an Iceland *Kennitala*.
1209///
1210/// 10 digits `DDMMYYRRCN`. Check digit uses weights `[3,2,7,6,5,4,3,2]`
1211/// over the first 8 digits; mod 11 = 10 is invalid.
1212///
1213/// ```
1214/// use worker_matcher::identifiers::parse_is_kt;
1215/// assert_eq!(parse_is_kt("1501802529"), Some("1501802529".to_string()));
1216/// assert_eq!(parse_is_kt("1501802539"), None); // wrong check digit
1217/// ```
1218pub fn parse_is_kt(s: &str) -> Option<String> {
1219 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1220 if digits.len() != 10 {
1221 return None;
1222 }
1223 const WEIGHTS: [u32; 8] = [3, 2, 7, 6, 5, 4, 3, 2];
1224 let mut sum: u32 = 0;
1225 for (i, c) in digits.chars().take(8).enumerate() {
1226 sum += c.to_digit(10)? * WEIGHTS[i];
1227 }
1228 let r = sum % 11;
1229 if r == 10 {
1230 return None;
1231 }
1232 let expected = (11 - r) % 11;
1233 if digits.chars().nth(8)?.to_digit(10)? == expected {
1234 Some(digits)
1235 } else {
1236 None
1237 }
1238}
1239
1240/// Parse a Lithuania *Asmens kodas*.
1241///
1242/// 11 digits `GYYMMDDNNNC` with the same cascading Mod-11 check as Estonia.
1243///
1244/// ```
1245/// use worker_matcher::identifiers::parse_lt_ak;
1246/// assert_eq!(parse_lt_ak("48001150011"), Some("48001150011".to_string()));
1247/// assert_eq!(parse_lt_ak("48001150012"), None);
1248/// ```
1249pub fn parse_lt_ak(s: &str) -> Option<String> {
1250 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1251 if digits.len() != 11 {
1252 return None;
1253 }
1254 let expected = baltic_cascade_check(&digits[..10])?;
1255 if digits.chars().nth(10)?.to_digit(10)? == expected {
1256 Some(digits)
1257 } else {
1258 None
1259 }
1260}
1261
1262/// Parse a Latvia *Workeras kods*.
1263///
1264/// 11 digits `DDMMYYCZZZK`. Check uses weights `[1,6,3,7,9,10,5,8,4,2]`
1265/// over the first 10 digits; `check = ((1101 − Σ) mod 11) mod 10`.
1266///
1267/// ```
1268/// use worker_matcher::identifiers::parse_lv_pk;
1269/// assert_eq!(parse_lv_pk("15018010007"), Some("15018010007".to_string()));
1270/// assert_eq!(parse_lv_pk("15018010008"), None);
1271/// ```
1272pub fn parse_lv_pk(s: &str) -> Option<String> {
1273 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1274 if digits.len() != 11 {
1275 return None;
1276 }
1277 const WEIGHTS: [i32; 10] = [1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
1278 let mut sum: i32 = 0;
1279 for (i, c) in digits.chars().take(10).enumerate() {
1280 sum += (c.to_digit(10)? as i32) * WEIGHTS[i];
1281 }
1282 let expected = ((1101 - sum).rem_euclid(11)) % 10;
1283 if digits.chars().nth(10)?.to_digit(10)? as i32 == expected {
1284 Some(digits)
1285 } else {
1286 None
1287 }
1288}
1289
1290/// Parse a Malta National ID.
1291///
1292/// 7 digits + 1 letter from `{M, G, A, P, L, H, B, Z}`. Format-only — the
1293/// suffix letter encodes geographic / registration provenance and is not a
1294/// check digit.
1295///
1296/// ```
1297/// use worker_matcher::identifiers::parse_mt_id;
1298/// assert_eq!(parse_mt_id("1234567M"), Some("1234567M".to_string()));
1299/// assert_eq!(parse_mt_id("1234567X"), None); // X not in valid letter set
1300/// ```
1301pub fn parse_mt_id(s: &str) -> Option<String> {
1302 let cleaned: String = s
1303 .chars()
1304 .filter(|c| c.is_ascii_alphanumeric())
1305 .collect::<String>()
1306 .to_uppercase();
1307 if cleaned.len() != 8 {
1308 return None;
1309 }
1310 let last = cleaned.chars().last()?;
1311 if !matches!(last, 'M' | 'G' | 'A' | 'P' | 'L' | 'H' | 'B' | 'Z') {
1312 return None;
1313 }
1314 if !cleaned[..7].chars().all(|c| c.is_ascii_digit()) {
1315 return None;
1316 }
1317 Some(cleaned)
1318}
1319
1320/// Parse a Norway *Fødselsnummer*.
1321///
1322/// 11 digits with two Mod-11 check digits. Check 1 weights:
1323/// `[3,7,6,1,8,9,4,5,2]` over the first 9 digits. Check 2 weights:
1324/// `[5,4,3,2,7,6,5,4,3,2]` over the first 10. mod 11 = 10 is invalid.
1325///
1326/// ```
1327/// use worker_matcher::identifiers::parse_no_fnr;
1328/// assert_eq!(parse_no_fnr("15018012399"), Some("15018012399".to_string()));
1329/// assert_eq!(parse_no_fnr("15018012390"), None);
1330/// ```
1331pub fn parse_no_fnr(s: &str) -> Option<String> {
1332 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1333 if digits.len() != 11 {
1334 return None;
1335 }
1336 const W1: [u32; 9] = [3, 7, 6, 1, 8, 9, 4, 5, 2];
1337 const W2: [u32; 10] = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
1338 let body: Vec<u32> = digits.chars().filter_map(|c| c.to_digit(10)).collect();
1339 if body.len() != 11 {
1340 return None;
1341 }
1342 let s1: u32 = body.iter().take(9).zip(W1.iter()).map(|(d, w)| d * w).sum();
1343 let r1 = s1 % 11;
1344 if r1 == 10 {
1345 return None;
1346 }
1347 let c1 = (11 - r1) % 11;
1348 if c1 != body[9] {
1349 return None;
1350 }
1351 let s2: u32 = body
1352 .iter()
1353 .take(10)
1354 .zip(W2.iter())
1355 .map(|(d, w)| d * w)
1356 .sum();
1357 let r2 = s2 % 11;
1358 if r2 == 10 {
1359 return None;
1360 }
1361 let c2 = (11 - r2) % 11;
1362 if c2 != body[10] {
1363 return None;
1364 }
1365 Some(digits)
1366}
1367
1368/// Parse a Poland PESEL.
1369///
1370/// 11 digits `YYMMDDZZZZK` with century-encoded month. Check uses weights
1371/// `[1,3,7,9,1,3,7,9,1,3]` over the first 10 digits;
1372/// `check = (10 − (Σ mod 10)) mod 10`.
1373///
1374/// ```
1375/// use worker_matcher::identifiers::parse_pl_pesel;
1376/// assert_eq!(parse_pl_pesel("80011500014"), Some("80011500014".to_string()));
1377/// assert_eq!(parse_pl_pesel("80011500015"), None);
1378/// ```
1379pub fn parse_pl_pesel(s: &str) -> Option<String> {
1380 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1381 if digits.len() != 11 {
1382 return None;
1383 }
1384 const WEIGHTS: [u32; 10] = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
1385 let mut sum: u32 = 0;
1386 for (i, c) in digits.chars().take(10).enumerate() {
1387 sum += c.to_digit(10)? * WEIGHTS[i];
1388 }
1389 let expected = (10 - (sum % 10)) % 10;
1390 if digits.chars().nth(10)?.to_digit(10)? == expected {
1391 Some(digits)
1392 } else {
1393 None
1394 }
1395}
1396
1397/// Parse a Romania CNP (*Cod Numeric Workeral*).
1398///
1399/// 13 digits `SYYMMDDJJNNNK`. Check uses weights "279146358279" (`[2,7,9,1,
1400/// 4,6,3,5,8,2,7,9]`) over the first 12 digits; `r = Σ mod 11`; check is
1401/// `1` if `r == 10`, else `r`.
1402///
1403/// ```
1404/// use worker_matcher::identifiers::parse_ro_cnp;
1405/// assert_eq!(parse_ro_cnp("1800115400012"), Some("1800115400012".to_string()));
1406/// assert_eq!(parse_ro_cnp("1800115400015"), None);
1407/// ```
1408pub fn parse_ro_cnp(s: &str) -> Option<String> {
1409 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1410 if digits.len() != 13 {
1411 return None;
1412 }
1413 const WEIGHTS: [u32; 12] = [2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9];
1414 let mut sum: u32 = 0;
1415 for (i, c) in digits.chars().take(12).enumerate() {
1416 sum += c.to_digit(10)? * WEIGHTS[i];
1417 }
1418 let r = sum % 11;
1419 let expected = if r == 10 { 1 } else { r };
1420 if digits.chars().nth(12)?.to_digit(10)? == expected {
1421 Some(digits)
1422 } else {
1423 None
1424 }
1425}
1426
1427/// Parse a Slovenia EMŠO (*Enotna Matična Številka Občana*).
1428///
1429/// 13 digits `DDMMYYYRRGGGK`. Check uses weights `[7,6,5,4,3,2,7,6,5,4,3,2]`
1430/// over the first 12 digits; `r = Σ mod 11`; check is `0` if `r == 0`,
1431/// else `11 − r` (rejected if 10).
1432///
1433/// ```
1434/// use worker_matcher::identifiers::parse_si_emso;
1435/// assert_eq!(parse_si_emso("1501980500015"), Some("1501980500015".to_string()));
1436/// assert_eq!(parse_si_emso("1501980500014"), None);
1437/// ```
1438pub fn parse_si_emso(s: &str) -> Option<String> {
1439 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1440 if digits.len() != 13 {
1441 return None;
1442 }
1443 const WEIGHTS: [u32; 12] = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
1444 let mut sum: u32 = 0;
1445 for (i, c) in digits.chars().take(12).enumerate() {
1446 sum += c.to_digit(10)? * WEIGHTS[i];
1447 }
1448 let r = sum % 11;
1449 let expected = if r == 0 { 0 } else { 11 - r };
1450 if expected == 10 {
1451 return None;
1452 }
1453 if digits.chars().nth(12)?.to_digit(10)? == expected {
1454 Some(digits)
1455 } else {
1456 None
1457 }
1458}
1459
1460/// Parse a Slovakia *Rodné číslo*. Same algorithm as Czech RČ.
1461///
1462/// ```
1463/// use worker_matcher::identifiers::parse_sk_rc;
1464/// assert_eq!(parse_sk_rc("8051150019"), Some("8051150019".to_string()));
1465/// assert_eq!(parse_sk_rc("8051150010"), None);
1466/// ```
1467pub fn parse_sk_rc(s: &str) -> Option<String> {
1468 parse_cz_rc(s)
1469}
1470
1471/// Parse a United Kingdom National Insurance Number (NINO).
1472///
1473/// Format `AA999999A`: 2 prefix letters + 6 digits + 1 suffix letter.
1474/// Banned first prefix letters: `D F I Q U V`.
1475/// Banned second prefix letters: `D F I O Q U V`.
1476/// Banned admin prefixes: `OO CR FY MW NC PP PZ TN`.
1477/// Suffix MUST be one of `A B C D`. Format-only; no checksum.
1478///
1479/// ```
1480/// use worker_matcher::identifiers::parse_uk_nino;
1481/// assert_eq!(parse_uk_nino("AB123456A"), Some("AB123456A".to_string()));
1482/// assert_eq!(parse_uk_nino("ab 12 34 56 a"), Some("AB123456A".to_string()));
1483/// assert_eq!(parse_uk_nino("DA123456A"), None); // banned first letter
1484/// assert_eq!(parse_uk_nino("ABCDEFGHI"), None);
1485/// ```
1486pub fn parse_uk_nino(s: &str) -> Option<String> {
1487 let cleaned: String = s
1488 .chars()
1489 .filter(|c| c.is_ascii_alphanumeric())
1490 .collect::<String>()
1491 .to_uppercase();
1492 if cleaned.len() != 9 {
1493 return None;
1494 }
1495 let bytes = cleaned.as_bytes();
1496 let p1 = bytes[0] as char;
1497 let p2 = bytes[1] as char;
1498 if !p1.is_ascii_alphabetic() || !p2.is_ascii_alphabetic() {
1499 return None;
1500 }
1501 if matches!(p1, 'D' | 'F' | 'I' | 'Q' | 'U' | 'V') {
1502 return None;
1503 }
1504 if matches!(p2, 'D' | 'F' | 'I' | 'O' | 'Q' | 'U' | 'V') {
1505 return None;
1506 }
1507 let prefix = &cleaned[..2];
1508 if matches!(
1509 prefix,
1510 "OO" | "CR" | "FY" | "MW" | "NC" | "PP" | "PZ" | "TN"
1511 ) {
1512 return None;
1513 }
1514 if !cleaned[2..8].chars().all(|c| c.is_ascii_digit()) {
1515 return None;
1516 }
1517 let suffix = bytes[8] as char;
1518 if !matches!(suffix, 'A' | 'B' | 'C' | 'D') {
1519 return None;
1520 }
1521 Some(cleaned)
1522}
1523
1524// ----------------------------------------------------------------------------
1525// Five additional workeral national identifiers (T-28).
1526// ----------------------------------------------------------------------------
1527
1528/// Parse a Greece DSS (Dematerialised Securities System) investor share code.
1529///
1530/// 10-digit investor identifier issued by the Hellenic Central Securities
1531/// Depository (ATHEXCSD). Format-only validation: 10 ASCII digits.
1532///
1533/// ```
1534/// use worker_matcher::identifiers::parse_gr_dss;
1535/// assert_eq!(parse_gr_dss("1234567890"), Some("1234567890".to_string()));
1536/// assert_eq!(parse_gr_dss("12345"), None);
1537/// assert_eq!(parse_gr_dss("ABCDEFGHIJ"), None);
1538/// ```
1539pub fn parse_gr_dss(s: &str) -> Option<String> {
1540 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1541 if digits.len() == 10 {
1542 Some(digits)
1543 } else {
1544 None
1545 }
1546}
1547
1548/// Parse a Liechtenstein National Identity Card Number.
1549///
1550/// Combination of 2 letters and 8 digits (the example in the spec
1551/// `ID022143586` shows a 9-digit run, so the parser accepts 8 *or* 9
1552/// trailing digits — total length 10 or 11). Note: per Liechtenstein
1553/// practice the number is **regenerated on each renewal**, so for
1554/// cross-renewal matching consumers should prefer
1555/// [`crate::PassportBook`] with `country = "LI"`.
1556///
1557/// ```
1558/// use worker_matcher::identifiers::parse_li_id;
1559/// assert_eq!(parse_li_id("ID12345678"), Some("ID12345678".to_string()));
1560/// assert_eq!(parse_li_id("ID022143586"), Some("ID022143586".to_string()));
1561/// assert_eq!(parse_li_id("12 34 56 78"), None); // missing letters
1562/// assert_eq!(parse_li_id(""), None);
1563/// ```
1564pub fn parse_li_id(s: &str) -> Option<String> {
1565 let cleaned: String = s
1566 .chars()
1567 .filter(|c| c.is_ascii_alphanumeric())
1568 .collect::<String>()
1569 .to_uppercase();
1570 if !(10..=11).contains(&cleaned.len()) {
1571 return None;
1572 }
1573 let chars: Vec<char> = cleaned.chars().collect();
1574 if !chars[0].is_ascii_alphabetic() || !chars[1].is_ascii_alphabetic() {
1575 return None;
1576 }
1577 if !chars[2..].iter().all(|c| c.is_ascii_digit()) {
1578 return None;
1579 }
1580 Some(cleaned)
1581}
1582
1583/// Parse a Netherlands National Identity Card Number.
1584///
1585/// 9 characters: positions 1 and 2 are uppercase letters `[A-Z]` except
1586/// `O`; positions 3–8 are alphanumeric `[A-Z0-9]` except `O`; position 9
1587/// is a digit `[0-9]`. The character `O` is disallowed (to avoid
1588/// confusion with `0`), but the digit `0` is allowed.
1589///
1590/// ```
1591/// use worker_matcher::identifiers::parse_nl_id;
1592/// assert_eq!(parse_nl_id("AB1234567"), Some("AB1234567".to_string()));
1593/// assert_eq!(parse_nl_id("ab 12 34 567"), Some("AB1234567".to_string()));
1594/// assert_eq!(parse_nl_id("AO1234567"), None); // O is banned
1595/// assert_eq!(parse_nl_id("AB12345AB"), None); // last must be digit
1596/// assert_eq!(parse_nl_id("12345AB67"), None); // leading must be letters
1597/// ```
1598pub fn parse_nl_id(s: &str) -> Option<String> {
1599 let cleaned: String = s
1600 .chars()
1601 .filter(|c| c.is_ascii_alphanumeric())
1602 .collect::<String>()
1603 .to_uppercase();
1604 if cleaned.len() != 9 {
1605 return None;
1606 }
1607 let chars: Vec<char> = cleaned.chars().collect();
1608 for c in chars.iter().take(2) {
1609 if !c.is_ascii_alphabetic() || *c == 'O' {
1610 return None;
1611 }
1612 }
1613 for c in chars.iter().take(8).skip(2) {
1614 if !c.is_ascii_alphanumeric() || *c == 'O' {
1615 return None;
1616 }
1617 }
1618 if !chars[8].is_ascii_digit() {
1619 return None;
1620 }
1621 Some(cleaned)
1622}
1623
1624/// Parse a Poland NIP (*Numer Identyfikacji Podatkowej*).
1625///
1626/// 10 digits with a weighted Mod-11 check. Weights for the first 9
1627/// digits: `[6, 5, 7, 2, 3, 4, 5, 6, 7]`. `r = Σ mod 11`; a remainder
1628/// of 10 indicates an invalid NIP; otherwise the 10th digit MUST equal
1629/// `r`.
1630///
1631/// ```
1632/// use worker_matcher::identifiers::parse_pl_nip;
1633/// assert_eq!(parse_pl_nip("1234567802"), Some("1234567802".to_string()));
1634/// assert_eq!(parse_pl_nip("123-456-78-02"), Some("1234567802".to_string()));
1635/// assert_eq!(parse_pl_nip("1234567803"), None); // wrong check
1636/// assert_eq!(parse_pl_nip("1234567890"), None); // r = 10 — invalid by spec
1637/// ```
1638pub fn parse_pl_nip(s: &str) -> Option<String> {
1639 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1640 if digits.len() != 10 {
1641 return None;
1642 }
1643 const WEIGHTS: [u32; 9] = [6, 5, 7, 2, 3, 4, 5, 6, 7];
1644 let mut sum: u32 = 0;
1645 for (i, c) in digits.chars().take(9).enumerate() {
1646 sum += c.to_digit(10)? * WEIGHTS[i];
1647 }
1648 let r = sum % 11;
1649 if r == 10 {
1650 return None;
1651 }
1652 if digits.chars().nth(9)?.to_digit(10)? == r {
1653 Some(digits)
1654 } else {
1655 None
1656 }
1657}
1658
1659/// Parse a Portugal NIF (*Número de Identificação Fiscal*).
1660///
1661/// 9 digits with a weighted Mod-11 check. Weights for the first 8
1662/// digits: `[9, 8, 7, 6, 5, 4, 3, 2]`. `r = Σ mod 11`; check is `0` if
1663/// `r < 2`, else `11 − r`. The 9th digit MUST equal the check.
1664///
1665/// ```
1666/// use worker_matcher::identifiers::parse_pt_nif;
1667/// assert_eq!(parse_pt_nif("123456789"), Some("123456789".to_string()));
1668/// assert_eq!(parse_pt_nif("123 456 789"), Some("123456789".to_string()));
1669/// assert_eq!(parse_pt_nif("123456780"), None);
1670/// ```
1671pub fn parse_pt_nif(s: &str) -> Option<String> {
1672 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1673 if digits.len() != 9 {
1674 return None;
1675 }
1676 const WEIGHTS: [u32; 8] = [9, 8, 7, 6, 5, 4, 3, 2];
1677 let mut sum: u32 = 0;
1678 for (i, c) in digits.chars().take(8).enumerate() {
1679 sum += c.to_digit(10)? * WEIGHTS[i];
1680 }
1681 let r = sum % 11;
1682 let expected = if r < 2 { 0 } else { 11 - r };
1683 if digits.chars().nth(8)?.to_digit(10)? == expected {
1684 Some(digits)
1685 } else {
1686 None
1687 }
1688}
1689
1690// ----------------------------------------------------------------------------
1691// T-17.1 — seven next-batch national identifier schemes.
1692//
1693// Per the T-17 spike (§21.4 / §23.2): one parser per jurisdiction the
1694// crate already supports phones for but not national IDs.
1695// ----------------------------------------------------------------------------
1696
1697/// Parse a Brazil CPF (*Cadastro de Pessoas Físicas*).
1698///
1699/// The CPF is 11 digits, often formatted `NNN.NNN.NNN-DD`. The last two
1700/// digits are computed check digits using weighted Mod-11. The parser
1701/// strips non-digits, requires exactly 11 digits, rejects all-equal
1702/// sequences (the canonical sentinel / test vectors a real CPF MUST
1703/// NOT take), and validates both check digits.
1704///
1705/// ```
1706/// use worker_matcher::identifiers::parse_br_cpf;
1707/// assert_eq!(parse_br_cpf("123.456.789-09"), Some("12345678909".to_string()));
1708/// assert_eq!(parse_br_cpf("12345678909"), Some("12345678909".to_string()));
1709/// assert_eq!(parse_br_cpf("12345678900"), None); // wrong D2
1710/// assert_eq!(parse_br_cpf("11111111111"), None); // all-equal sentinel
1711/// assert_eq!(parse_br_cpf("1234567890"), None); // too short
1712/// ```
1713pub fn parse_br_cpf(s: &str) -> Option<String> {
1714 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1715 if digits.len() != 11 {
1716 return None;
1717 }
1718 let bytes = digits.as_bytes();
1719 if bytes.iter().all(|&b| b == bytes[0]) {
1720 return None;
1721 }
1722 let d = |i: usize| (bytes[i] - b'0') as u32;
1723 let mut sum1: u32 = 0;
1724 for i in 0..9 {
1725 sum1 += d(i) * (10 - i as u32);
1726 }
1727 let r1 = sum1 % 11;
1728 let exp1 = if r1 < 2 { 0 } else { 11 - r1 };
1729 if d(9) != exp1 {
1730 return None;
1731 }
1732 let mut sum2: u32 = 0;
1733 for i in 0..10 {
1734 sum2 += d(i) * (11 - i as u32);
1735 }
1736 let r2 = sum2 % 11;
1737 let exp2 = if r2 < 2 { 0 } else { 11 - r2 };
1738 if d(10) != exp2 {
1739 return None;
1740 }
1741 Some(digits)
1742}
1743
1744/// Parse a China Resident Identity Card number (*居民身份证*, 18-digit
1745/// 1999 reform).
1746///
1747/// 18 characters: 17 digits + a check character (digit or `X`). The
1748/// substring at positions 6..14 encodes the date of birth (`YYYYMMDD`)
1749/// and MUST be a valid calendar date. The check character is computed
1750/// from a weighted Mod-11 sum over the 17 leading digits with the
1751/// lookup `1,0,X,9,8,7,6,5,4,3,2`. Lowercase `x` is accepted and
1752/// canonicalised to uppercase. The 15-digit pre-1999 form is NOT
1753/// accepted; consumers MUST migrate to the 18-digit form before
1754/// matching (the conversion is well-documented but jurisdiction-locked
1755/// and out of scope for this parser).
1756///
1757/// ```
1758/// use worker_matcher::identifiers::parse_cn_rrn;
1759/// assert_eq!(
1760/// parse_cn_rrn("11010519491231002X"),
1761/// Some("11010519491231002X".to_string()),
1762/// );
1763/// assert_eq!(
1764/// parse_cn_rrn("11010519491231002x"),
1765/// Some("11010519491231002X".to_string()),
1766/// );
1767/// assert_eq!(parse_cn_rrn("11010519491231002Y"), None); // wrong check
1768/// assert_eq!(parse_cn_rrn("11010513491231002X"), None); // invalid month
1769/// assert_eq!(parse_cn_rrn("110105194912310020"), None); // wrong check
1770/// assert_eq!(parse_cn_rrn("11010519491231"), None); // too short
1771/// ```
1772pub fn parse_cn_rrn(s: &str) -> Option<String> {
1773 let cleaned: String = s
1774 .chars()
1775 .filter(|c| c.is_ascii_alphanumeric())
1776 .map(|c| c.to_ascii_uppercase())
1777 .collect();
1778 if cleaned.len() != 18 {
1779 return None;
1780 }
1781 let bytes = cleaned.as_bytes();
1782 for &b in &bytes[..17] {
1783 if !b.is_ascii_digit() {
1784 return None;
1785 }
1786 }
1787 if !bytes[17].is_ascii_digit() && bytes[17] != b'X' {
1788 return None;
1789 }
1790 let yyyy: i32 = cleaned[6..10].parse().ok()?;
1791 let mm: u32 = cleaned[10..12].parse().ok()?;
1792 let dd: u32 = cleaned[12..14].parse().ok()?;
1793 chrono::NaiveDate::from_ymd_opt(yyyy, mm, dd)?;
1794 const WEIGHTS: [u32; 17] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
1795 const CHECK: [u8; 11] = [
1796 b'1', b'0', b'X', b'9', b'8', b'7', b'6', b'5', b'4', b'3', b'2',
1797 ];
1798 let mut sum: u32 = 0;
1799 for i in 0..17 {
1800 sum += u32::from(bytes[i] - b'0') * WEIGHTS[i];
1801 }
1802 if bytes[17] != CHECK[(sum % 11) as usize] {
1803 return None;
1804 }
1805 Some(cleaned)
1806}
1807
1808/// Parse an India Aadhaar number (12 digits, Verhoeff check digit).
1809///
1810/// The Verhoeff algorithm uses two precomputed lookup tables (the
1811/// dihedral-group multiplication table `D` and the permutation table
1812/// `P`) and runs in linear time over the input. The parser strips
1813/// non-digits, requires exactly 12 digits, rejects all-equal sequences
1814/// and the UIDAI-test-prefix ranges (numbers beginning with `0` or
1815/// `1`, which UIDAI guidance reserves and never issues to real
1816/// citizens), and validates the Verhoeff check digit at the rightmost
1817/// position.
1818///
1819/// ```
1820/// use worker_matcher::identifiers::parse_in_aadhaar;
1821/// assert_eq!(parse_in_aadhaar("234123412346"), Some("234123412346".to_string()));
1822/// assert_eq!(parse_in_aadhaar("2341 2341 2346"), Some("234123412346".to_string()));
1823/// assert_eq!(parse_in_aadhaar("234123412347"), None); // wrong check
1824/// assert_eq!(parse_in_aadhaar("234123412"), None); // too short
1825/// assert_eq!(parse_in_aadhaar("222222222222"), None); // all-equal sentinel
1826/// assert_eq!(parse_in_aadhaar("034123412346"), None); // reserved prefix
1827/// ```
1828pub fn parse_in_aadhaar(s: &str) -> Option<String> {
1829 const VERHOEFF_D: [[u8; 10]; 10] = [
1830 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1831 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
1832 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
1833 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
1834 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
1835 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
1836 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
1837 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
1838 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
1839 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
1840 ];
1841 const VERHOEFF_P: [[u8; 10]; 8] = [
1842 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1843 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
1844 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
1845 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
1846 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
1847 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
1848 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
1849 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
1850 ];
1851 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1852 if digits.len() != 12 {
1853 return None;
1854 }
1855 let bytes = digits.as_bytes();
1856 if bytes.iter().all(|&b| b == bytes[0]) {
1857 return None;
1858 }
1859 if bytes[0] == b'0' || bytes[0] == b'1' {
1860 return None;
1861 }
1862 let mut c: u8 = 0;
1863 for i in 0..12 {
1864 let d = bytes[11 - i] - b'0';
1865 c = VERHOEFF_D[c as usize][VERHOEFF_P[i % 8][d as usize] as usize];
1866 }
1867 if c == 0 { Some(digits) } else { None }
1868}
1869
1870/// Parse a Japan My Number (個人番号, 12 digits).
1871///
1872/// The check digit is computed by a weighted Mod-11 sum over the
1873/// first 11 digits using the weights `[6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2]`
1874/// (per the Japanese e-Gov Cabinet Order specification). If the
1875/// remainder is `< 2`, the check digit is `0`; otherwise it is
1876/// `11 - remainder`.
1877///
1878/// ```
1879/// use worker_matcher::identifiers::parse_jp_my_number;
1880/// assert_eq!(parse_jp_my_number("123456789018"), Some("123456789018".to_string()));
1881/// assert_eq!(parse_jp_my_number("1234 5678 9018"), Some("123456789018".to_string()));
1882/// assert_eq!(parse_jp_my_number("123456789010"), None); // wrong check
1883/// assert_eq!(parse_jp_my_number("12345678901"), None); // too short
1884/// ```
1885pub fn parse_jp_my_number(s: &str) -> Option<String> {
1886 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
1887 if digits.len() != 12 {
1888 return None;
1889 }
1890 let bytes = digits.as_bytes();
1891 const WEIGHTS: [u32; 11] = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
1892 let mut sum: u32 = 0;
1893 for i in 0..11 {
1894 sum += u32::from(bytes[i] - b'0') * WEIGHTS[i];
1895 }
1896 let r = sum % 11;
1897 let expected = if r < 2 { 0 } else { 11 - r };
1898 if u32::from(bytes[11] - b'0') != expected {
1899 return None;
1900 }
1901 Some(digits)
1902}
1903
1904/// Parse a Mexico CURP (*Clave Única de Registro de Población*).
1905///
1906/// 18 characters with rich internal structure: 4 letters (surname /
1907/// given-name initials), 6 digits (`YYMMDD`), 1 sex char (`H` or `M`),
1908/// 2 letters (state code), 3 letters (internal consonants), 1
1909/// alphanumeric (homonym discriminator), 1 check digit. The parser
1910/// uppercases, validates the structural shape, verifies the embedded
1911/// date of birth is a valid calendar date (century inferred per the
1912/// usual Mexican convention: `YY <= 29 → 20YY`, else `19YY`), and
1913/// validates the Mod-10 weighted check digit using the standard CURP
1914/// value table (`0..9` literal, `A..N` = 10..23, `Ñ` = 24,
1915/// `O..Z` = 25..36).
1916///
1917/// Ñ in the input is accepted; non-ASCII characters other than Ñ are
1918/// rejected.
1919///
1920/// ```
1921/// use worker_matcher::identifiers::parse_mx_curp;
1922/// assert_eq!(
1923/// parse_mx_curp("HEGG560427MVZRRL04"),
1924/// Some("HEGG560427MVZRRL04".to_string()),
1925/// );
1926/// assert_eq!(
1927/// parse_mx_curp("hegg560427mvzrrl04"),
1928/// Some("HEGG560427MVZRRL04".to_string()),
1929/// );
1930/// assert_eq!(parse_mx_curp("HEGG560427MVZRRL05"), None); // wrong check
1931/// assert_eq!(parse_mx_curp("HEGG561327MVZRRL04"), None); // invalid month
1932/// assert_eq!(parse_mx_curp("HEGG560427"), None); // too short
1933/// ```
1934pub fn parse_mx_curp(s: &str) -> Option<String> {
1935 let cleaned: String = s
1936 .chars()
1937 .filter(|c| !c.is_whitespace())
1938 .map(|c| c.to_uppercase().next().unwrap_or(c))
1939 .collect();
1940 if cleaned.chars().count() != 18 {
1941 return None;
1942 }
1943 let chars: Vec<char> = cleaned.chars().collect();
1944 let is_letter_or_n_tilde = |c: char| c.is_ascii_uppercase() || c == 'Ñ';
1945 if !chars[..4].iter().copied().all(is_letter_or_n_tilde) {
1946 return None;
1947 }
1948 if !chars[4..10].iter().all(|c| c.is_ascii_digit()) {
1949 return None;
1950 }
1951 if chars[10] != 'H' && chars[10] != 'M' {
1952 return None;
1953 }
1954 if !chars[11..16].iter().copied().all(is_letter_or_n_tilde) {
1955 return None;
1956 }
1957 if !chars[16].is_ascii_alphanumeric() && chars[16] != 'Ñ' {
1958 return None;
1959 }
1960 if !chars[17].is_ascii_digit() {
1961 return None;
1962 }
1963 let yy: i32 = cleaned[4..6].parse().ok()?;
1964 let mm: u32 = cleaned[6..8].parse().ok()?;
1965 let dd: u32 = cleaned[8..10].parse().ok()?;
1966 let yyyy = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1967 chrono::NaiveDate::from_ymd_opt(yyyy, mm, dd)?;
1968 let value = |c: char| -> Option<u32> {
1969 Some(match c {
1970 '0'..='9' => (c as u32) - ('0' as u32),
1971 'A'..='N' => 10 + ((c as u32) - ('A' as u32)),
1972 'Ñ' => 24,
1973 'O'..='Z' => 25 + ((c as u32) - ('O' as u32)),
1974 _ => return None,
1975 })
1976 };
1977 let mut sum: u32 = 0;
1978 for (i, &c) in chars.iter().enumerate().take(17) {
1979 sum += value(c)? * (18 - i as u32);
1980 }
1981 let expected = (10 - (sum % 10)) % 10;
1982 if u32::from(chars[17] as u8 - b'0') != expected {
1983 return None;
1984 }
1985 Some(cleaned)
1986}
1987
1988/// Parse a New Zealand NHI Number (original 7-character format:
1989/// 3 letters + 4 digits, where the last digit is a Mod-11 check).
1990///
1991/// The letter values are: `A..Z` minus `I` and `O` (excluded because
1992/// they collide visually with `1` and `0`), assigned consecutively:
1993/// `A=1, B=2, C=3, D=4, E=5, F=6, G=7, H=8, J=9, K=10, L=11, M=12,
1994/// N=13, P=14, Q=15, R=16, S=17, T=18, U=19, V=20, W=21, X=22, Y=23,
1995/// Z=24`. The weighted sum applies weights `[7, 6, 5, 4, 3, 2]` to
1996/// the first six positions (3 letters + 3 digits); the remainder mod
1997/// 11 yields the expected check digit (`0` if remainder is `0`,
1998/// otherwise `11 - remainder`; if the result is `10` the NHI is
1999/// invalid because no single decimal digit can encode `10`).
2000///
2001/// The 2019 7-character alphanumeric NHI revision (3 letters + 2
2002/// digits + 2 letters) uses a different algorithm and is **not**
2003/// supported by this parser; calls fall through to `None` for the new
2004/// format. Consumers handling 2019-format NHIs SHOULD validate
2005/// upstream and pass the value through as a third-party identifier.
2006///
2007/// ```
2008/// use worker_matcher::identifiers::parse_nz_nhi;
2009/// assert_eq!(parse_nz_nhi("ZAA0083"), Some("ZAA0083".to_string()));
2010/// assert_eq!(parse_nz_nhi("zaa0083"), Some("ZAA0083".to_string()));
2011/// assert_eq!(parse_nz_nhi("ZAA0082"), None); // wrong check
2012/// assert_eq!(parse_nz_nhi("ZAI0083"), None); // I excluded
2013/// assert_eq!(parse_nz_nhi("ZAA008"), None); // too short
2014/// ```
2015pub fn parse_nz_nhi(s: &str) -> Option<String> {
2016 let cleaned: String = s
2017 .chars()
2018 .filter(|c| c.is_ascii_alphanumeric())
2019 .map(|c| c.to_ascii_uppercase())
2020 .collect();
2021 if cleaned.len() != 7 {
2022 return None;
2023 }
2024 let bytes = cleaned.as_bytes();
2025 for &b in &bytes[..3] {
2026 if !b.is_ascii_uppercase() || b == b'I' || b == b'O' {
2027 return None;
2028 }
2029 }
2030 for &b in &bytes[3..] {
2031 if !b.is_ascii_digit() {
2032 return None;
2033 }
2034 }
2035 let letter_value = |b: u8| -> u32 {
2036 let idx = u32::from(b - b'A') + 1;
2037 if b > b'O' {
2038 idx - 2
2039 } else if b > b'I' {
2040 idx - 1
2041 } else {
2042 idx
2043 }
2044 };
2045 const WEIGHTS: [u32; 6] = [7, 6, 5, 4, 3, 2];
2046 let mut sum: u32 = 0;
2047 for i in 0..3 {
2048 sum += letter_value(bytes[i]) * WEIGHTS[i];
2049 }
2050 for i in 0..3 {
2051 sum += u32::from(bytes[3 + i] - b'0') * WEIGHTS[3 + i];
2052 }
2053 let r = sum % 11;
2054 if r == 1 {
2055 return None;
2056 }
2057 let expected = if r == 0 { 0 } else { 11 - r };
2058 if u32::from(bytes[6] - b'0') != expected {
2059 return None;
2060 }
2061 Some(cleaned)
2062}
2063
2064/// Parse a South Africa ID Number (13 digits, Luhn check digit + a
2065/// date-of-birth substring at positions 0..6).
2066///
2067/// The first 6 digits encode `YYMMDD`; the century is conventionally
2068/// inferred (`YY <= 29 → 20YY`, else `19YY`). The check digit at
2069/// position 12 is computed by the standard Luhn algorithm over all
2070/// 13 digits.
2071///
2072/// The remaining substrings (sequence at positions 6..10, citizenship
2073/// at position 10, and the legacy race indicator at position 11) are
2074/// intentionally NOT validated by this parser — they are demographic
2075/// information the worker-matcher layer does not use.
2076///
2077/// ```
2078/// use worker_matcher::identifiers::parse_za_id;
2079/// assert_eq!(parse_za_id("8001015009087"), Some("8001015009087".to_string()));
2080/// assert_eq!(parse_za_id("800101 5009 087"), Some("8001015009087".to_string()));
2081/// assert_eq!(parse_za_id("8001015009088"), None); // wrong Luhn
2082/// assert_eq!(parse_za_id("8013015009087"), None); // invalid month
2083/// assert_eq!(parse_za_id("80010150090"), None); // too short
2084/// ```
2085pub fn parse_za_id(s: &str) -> Option<String> {
2086 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
2087 if digits.len() != 13 {
2088 return None;
2089 }
2090 let yy: i32 = digits[0..2].parse().ok()?;
2091 let mm: u32 = digits[2..4].parse().ok()?;
2092 let dd: u32 = digits[4..6].parse().ok()?;
2093 let yyyy = if yy <= 29 { 2000 + yy } else { 1900 + yy };
2094 chrono::NaiveDate::from_ymd_opt(yyyy, mm, dd)?;
2095 let bytes = digits.as_bytes();
2096 let mut sum: u32 = 0;
2097 // Standard Luhn: process right-to-left, double every second digit
2098 // starting from the second-to-last (i.e. positions 1, 3, 5, … from
2099 // the right). For a 13-digit ID this doubles positions 11, 9, 7,
2100 // 5, 3, 1 (counting from the left, 0-indexed).
2101 for i in 0..13 {
2102 let mut d = u32::from(bytes[12 - i] - b'0');
2103 if i % 2 == 1 {
2104 d *= 2;
2105 if d > 9 {
2106 d -= 9;
2107 }
2108 }
2109 sum += d;
2110 }
2111 if !sum.is_multiple_of(10) {
2112 return None;
2113 }
2114 Some(digits)
2115}
2116
2117// ----------------------------------------------------------------------------
2118// Nine per-country passport-number format validators (T-28).
2119//
2120// These are pure format validators that consumers may call before
2121// constructing a `PassportBook`. They do NOT correspond to `Worker`
2122// fields — passport-book numbers belong to the `PassportBook` model
2123// because they change with each renewal and a worker may carry
2124// passports from multiple countries simultaneously.
2125// ----------------------------------------------------------------------------
2126
2127/// Parse a Cyprus passport number.
2128///
2129/// Pre-2010 passports: letter `E` + 6 digits (e.g. `E123456`).
2130/// Biometric passports issued from 13 December 2010 onwards: letter `K`
2131/// + 8 digits (e.g. `K12345678`).
2132///
2133/// ```
2134/// use worker_matcher::identifiers::parse_cy_passport;
2135/// assert_eq!(parse_cy_passport("E123456"), Some("E123456".to_string()));
2136/// assert_eq!(parse_cy_passport("k12345678"), Some("K12345678".to_string()));
2137/// assert_eq!(parse_cy_passport("A123456"), None);
2138/// assert_eq!(parse_cy_passport("E12345"), None); // too short
2139/// ```
2140pub fn parse_cy_passport(s: &str) -> Option<String> {
2141 let cleaned: String = s
2142 .chars()
2143 .filter(|c| c.is_ascii_alphanumeric())
2144 .collect::<String>()
2145 .to_uppercase();
2146 let chars: Vec<char> = cleaned.chars().collect();
2147 match (chars.first(), chars.len()) {
2148 (Some('E'), 7) if chars[1..].iter().all(|c| c.is_ascii_digit()) => Some(cleaned),
2149 (Some('K'), 9) if chars[1..].iter().all(|c| c.is_ascii_digit()) => Some(cleaned),
2150 _ => None,
2151 }
2152}
2153
2154/// Parse a Czech Republic passport number.
2155///
2156/// Usually an 8-digit number; per the TSV it may be longer. The parser
2157/// accepts 8 to 12 ASCII digits.
2158///
2159/// ```
2160/// use worker_matcher::identifiers::parse_cz_passport;
2161/// assert_eq!(parse_cz_passport("12345678"), Some("12345678".to_string()));
2162/// assert_eq!(parse_cz_passport("123-456-78"), Some("12345678".to_string()));
2163/// assert_eq!(parse_cz_passport("123"), None); // too short
2164/// ```
2165pub fn parse_cz_passport(s: &str) -> Option<String> {
2166 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
2167 if (8..=12).contains(&digits.len()) {
2168 Some(digits)
2169 } else {
2170 None
2171 }
2172}
2173
2174/// Parse a Liechtenstein passport number. 1 letter + 5 digits (e.g. `R00536`).
2175///
2176/// ```
2177/// use worker_matcher::identifiers::parse_li_passport;
2178/// assert_eq!(parse_li_passport("R00536"), Some("R00536".to_string()));
2179/// assert_eq!(parse_li_passport("r00536"), Some("R00536".to_string()));
2180/// assert_eq!(parse_li_passport("123456"), None);
2181/// ```
2182pub fn parse_li_passport(s: &str) -> Option<String> {
2183 let cleaned: String = s
2184 .chars()
2185 .filter(|c| c.is_ascii_alphanumeric())
2186 .collect::<String>()
2187 .to_uppercase();
2188 if cleaned.len() != 6 {
2189 return None;
2190 }
2191 let chars: Vec<char> = cleaned.chars().collect();
2192 if !chars[0].is_ascii_alphabetic() {
2193 return None;
2194 }
2195 if !chars[1..].iter().all(|c| c.is_ascii_digit()) {
2196 return None;
2197 }
2198 Some(cleaned)
2199}
2200
2201/// Parse a Lithuania passport number. 8 ASCII digits (also used on the
2202/// national ID card).
2203///
2204/// ```
2205/// use worker_matcher::identifiers::parse_lt_passport;
2206/// assert_eq!(parse_lt_passport("12345678"), Some("12345678".to_string()));
2207/// assert_eq!(parse_lt_passport("1234567"), None);
2208/// ```
2209pub fn parse_lt_passport(s: &str) -> Option<String> {
2210 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
2211 if digits.len() == 8 {
2212 Some(digits)
2213 } else {
2214 None
2215 }
2216}
2217
2218/// Parse a Malta passport number. 7 ASCII digits.
2219///
2220/// ```
2221/// use worker_matcher::identifiers::parse_mt_passport;
2222/// assert_eq!(parse_mt_passport("1234567"), Some("1234567".to_string()));
2223/// assert_eq!(parse_mt_passport("123"), None);
2224/// ```
2225pub fn parse_mt_passport(s: &str) -> Option<String> {
2226 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
2227 if digits.len() == 7 {
2228 Some(digits)
2229 } else {
2230 None
2231 }
2232}
2233
2234/// Parse a Netherlands passport number. Same shape as the NL ID card
2235/// (see [`parse_nl_id`]).
2236///
2237/// ```
2238/// use worker_matcher::identifiers::parse_nl_passport;
2239/// assert_eq!(parse_nl_passport("AB1234567"), Some("AB1234567".to_string()));
2240/// assert_eq!(parse_nl_passport("AO1234567"), None); // O is banned
2241/// ```
2242pub fn parse_nl_passport(s: &str) -> Option<String> {
2243 parse_nl_id(s)
2244}
2245
2246/// Parse a Portugal passport number. 1 letter + 6 digits.
2247///
2248/// ```
2249/// use worker_matcher::identifiers::parse_pt_passport;
2250/// assert_eq!(parse_pt_passport("A123456"), Some("A123456".to_string()));
2251/// assert_eq!(parse_pt_passport("AA12345"), None);
2252/// ```
2253pub fn parse_pt_passport(s: &str) -> Option<String> {
2254 let cleaned: String = s
2255 .chars()
2256 .filter(|c| c.is_ascii_alphanumeric())
2257 .collect::<String>()
2258 .to_uppercase();
2259 if cleaned.len() != 7 {
2260 return None;
2261 }
2262 let chars: Vec<char> = cleaned.chars().collect();
2263 if !chars[0].is_ascii_alphabetic() {
2264 return None;
2265 }
2266 if !chars[1..].iter().all(|c| c.is_ascii_digit()) {
2267 return None;
2268 }
2269 Some(cleaned)
2270}
2271
2272/// Parse a Romania passport number. 2 letters + 6 digits.
2273///
2274/// ```
2275/// use worker_matcher::identifiers::parse_ro_passport;
2276/// assert_eq!(parse_ro_passport("AB123456"), Some("AB123456".to_string()));
2277/// assert_eq!(parse_ro_passport("A1234567"), None);
2278/// ```
2279pub fn parse_ro_passport(s: &str) -> Option<String> {
2280 let cleaned: String = s
2281 .chars()
2282 .filter(|c| c.is_ascii_alphanumeric())
2283 .collect::<String>()
2284 .to_uppercase();
2285 if cleaned.len() != 8 {
2286 return None;
2287 }
2288 let chars: Vec<char> = cleaned.chars().collect();
2289 if !chars[..2].iter().all(|c| c.is_ascii_alphabetic()) {
2290 return None;
2291 }
2292 if !chars[2..].iter().all(|c| c.is_ascii_digit()) {
2293 return None;
2294 }
2295 Some(cleaned)
2296}
2297
2298/// Parse a Slovakia passport number. 2 letters + 7 digits.
2299///
2300/// ```
2301/// use worker_matcher::identifiers::parse_sk_passport;
2302/// assert_eq!(parse_sk_passport("AB1234567"), Some("AB1234567".to_string()));
2303/// assert_eq!(parse_sk_passport("AB12345"), None);
2304/// ```
2305pub fn parse_sk_passport(s: &str) -> Option<String> {
2306 let cleaned: String = s
2307 .chars()
2308 .filter(|c| c.is_ascii_alphanumeric())
2309 .collect::<String>()
2310 .to_uppercase();
2311 if cleaned.len() != 9 {
2312 return None;
2313 }
2314 let chars: Vec<char> = cleaned.chars().collect();
2315 if !chars[..2].iter().all(|c| c.is_ascii_alphabetic()) {
2316 return None;
2317 }
2318 if !chars[2..].iter().all(|c| c.is_ascii_digit()) {
2319 return None;
2320 }
2321 Some(cleaned)
2322}
2323
2324#[cfg(test)]
2325mod tests {
2326 use super::*;
2327
2328 // ---------- parse_uk_nhs_number ----------
2329
2330 #[test]
2331 fn uk_nhs_number_compact_form_parses() {
2332 assert_eq!(parse_uk_nhs_number("9434765919"), Some("9434765919".into()));
2333 }
2334
2335 #[test]
2336 fn uk_nhs_number_spaced_form_parses_to_same_canonical() {
2337 assert_eq!(
2338 parse_uk_nhs_number("943 476 5919"),
2339 parse_uk_nhs_number("9434765919"),
2340 );
2341 }
2342
2343 #[test]
2344 fn uk_nhs_number_rejects_letters_and_short_input() {
2345 assert_eq!(parse_uk_nhs_number("ABCDEFGHIJ"), None);
2346 assert_eq!(parse_uk_nhs_number("123"), None);
2347 assert_eq!(parse_uk_nhs_number(""), None);
2348 }
2349
2350 // ---------- parse_fr_nir ----------
2351
2352 #[test]
2353 fn fr_nir_round_trip_for_a_constructed_valid_value() {
2354 // Body 1801275123456 → key = 97 - (N mod 97) = 42. Verified by parse.
2355 let valid = "180127512345642";
2356 assert_eq!(parse_fr_nir(valid), Some(valid.into()));
2357 }
2358
2359 #[test]
2360 fn fr_nir_whitespace_is_tolerated() {
2361 assert_eq!(
2362 parse_fr_nir("1 80 12 75 123 456 42"),
2363 Some("180127512345642".into()),
2364 );
2365 }
2366
2367 #[test]
2368 fn fr_nir_rejects_wrong_check_key() {
2369 assert_eq!(parse_fr_nir("180127512345699"), None);
2370 }
2371
2372 #[test]
2373 fn fr_nir_rejects_wrong_length() {
2374 assert_eq!(parse_fr_nir("12345"), None);
2375 assert_eq!(parse_fr_nir("1234567890123456"), None); // 16 chars
2376 assert_eq!(parse_fr_nir(""), None);
2377 }
2378
2379 #[test]
2380 fn fr_nir_rejects_letters_in_digit_positions() {
2381 assert_eq!(parse_fr_nir("A80127512345642"), None);
2382 }
2383
2384 #[test]
2385 fn fr_nir_handles_corsica_2a() {
2386 let body = "184032A001234";
2387 let numeric: u64 = "1840319001234".parse().unwrap();
2388 let key = 97 - (numeric % 97);
2389 let nir = format!("{body}{key:02}");
2390 assert_eq!(parse_fr_nir(&nir), Some(nir.clone()));
2391 }
2392
2393 #[test]
2394 fn fr_nir_handles_corsica_2b() {
2395 let body = "184032B001234";
2396 let numeric: u64 = "1840318001234".parse().unwrap();
2397 let key = 97 - (numeric % 97);
2398 let nir = format!("{body}{key:02}");
2399 assert_eq!(parse_fr_nir(&nir), Some(nir.clone()));
2400 }
2401
2402 #[test]
2403 fn fr_nir_canonical_form_is_uppercased() {
2404 let body = "184032a001234";
2405 let numeric: u64 = "1840319001234".parse().unwrap();
2406 let key = 97 - (numeric % 97);
2407 let nir = format!("{body}{key:02}");
2408 let canonical = nir.to_uppercase();
2409 assert_eq!(parse_fr_nir(&nir), Some(canonical));
2410 }
2411
2412 // ---------- parse_es_tsi ----------
2413
2414 #[test]
2415 fn es_tsi_canonical_cip_sns_parses() {
2416 assert_eq!(
2417 parse_es_tsi("ABCD123456XY1234"),
2418 Some("ABCD123456XY1234".into()),
2419 );
2420 }
2421
2422 #[test]
2423 fn es_tsi_whitespace_and_hyphens_stripped() {
2424 assert_eq!(
2425 parse_es_tsi("abcd 123 456-xy1234"),
2426 Some("ABCD123456XY1234".into()),
2427 );
2428 }
2429
2430 #[test]
2431 fn es_tsi_rejects_too_short_or_too_long() {
2432 assert_eq!(parse_es_tsi("ABC123"), None);
2433 assert_eq!(parse_es_tsi("ABCDEF123456XY12345678"), None);
2434 }
2435
2436 #[test]
2437 fn es_tsi_rejects_non_alphanumerics() {
2438 assert_eq!(parse_es_tsi("ABC@123!XYZ"), None);
2439 }
2440
2441 #[test]
2442 fn es_tsi_rejects_non_ascii() {
2443 assert_eq!(parse_es_tsi("ABCDÑ12345XYZ"), None);
2444 }
2445
2446 // ---------- parse_ie_ihi ----------
2447
2448 #[test]
2449 fn ie_ihi_seven_digits_parses() {
2450 assert_eq!(parse_ie_ihi("1234567"), Some("1234567".into()));
2451 }
2452
2453 #[test]
2454 fn ie_ihi_punctuation_and_spaces_stripped() {
2455 assert_eq!(parse_ie_ihi("123 4567"), Some("1234567".into()));
2456 assert_eq!(parse_ie_ihi("123-45-67"), Some("1234567".into()));
2457 }
2458
2459 #[test]
2460 fn ie_ihi_rejects_wrong_digit_count() {
2461 assert_eq!(parse_ie_ihi("123456"), None);
2462 assert_eq!(parse_ie_ihi("12345678"), None);
2463 assert_eq!(parse_ie_ihi(""), None);
2464 }
2465
2466 #[test]
2467 fn ie_ihi_rejects_when_no_digits_present() {
2468 assert_eq!(parse_ie_ihi("ABCDEFG"), None);
2469 }
2470
2471 // ---------- parse_uk_hc_number ----------
2472
2473 #[test]
2474 fn uk_hc_number_matches_nhs_number_semantics() {
2475 assert_eq!(
2476 parse_uk_hc_number("9434765919"),
2477 parse_uk_nhs_number("9434765919"),
2478 );
2479 assert_eq!(
2480 parse_uk_hc_number("943 476 5919"),
2481 parse_uk_nhs_number("943 476 5919"),
2482 );
2483 }
2484
2485 #[test]
2486 fn uk_hc_number_rejects_letters() {
2487 assert_eq!(parse_uk_hc_number("ABCDEFGHIJ"), None);
2488 }
2489
2490 // ---------- parse_us_ssn ----------
2491
2492 #[test]
2493 fn us_ssn_canonical_compact_form_parses() {
2494 assert_eq!(parse_us_ssn("123456789"), Some("123456789".into()));
2495 }
2496
2497 #[test]
2498 fn us_ssn_hyphenated_form_parses_to_same_canonical() {
2499 assert_eq!(parse_us_ssn("123-45-6789"), parse_us_ssn("123456789"),);
2500 }
2501
2502 #[test]
2503 fn us_ssn_whitespace_variants_canonicalise_identically() {
2504 assert_eq!(parse_us_ssn("123 45 6789"), Some("123456789".into()),);
2505 assert_eq!(parse_us_ssn(" 123 45 6789 "), Some("123456789".into()),);
2506 }
2507
2508 #[test]
2509 fn us_ssn_rejects_invalid_area_numbers() {
2510 assert_eq!(parse_us_ssn("000-12-3456"), None);
2511 assert_eq!(parse_us_ssn("666-12-3456"), None);
2512 assert_eq!(parse_us_ssn("900-12-3456"), None);
2513 assert_eq!(parse_us_ssn("987-65-4321"), None); // 987 is in 900..=999
2514 assert_eq!(parse_us_ssn("999-99-9999"), None);
2515 }
2516
2517 #[test]
2518 fn us_ssn_accepts_boundary_areas() {
2519 // 001 and 899 are the lowest and highest valid area numbers.
2520 assert_eq!(parse_us_ssn("001-23-4567"), Some("001234567".into()));
2521 assert_eq!(parse_us_ssn("899-23-4567"), Some("899234567".into()));
2522 // 665 just below the 666 carve-out; 667 just above.
2523 assert_eq!(parse_us_ssn("665-23-4567"), Some("665234567".into()));
2524 assert_eq!(parse_us_ssn("667-23-4567"), Some("667234567".into()));
2525 }
2526
2527 #[test]
2528 fn us_ssn_rejects_zero_group() {
2529 assert_eq!(parse_us_ssn("123-00-4567"), None);
2530 }
2531
2532 #[test]
2533 fn us_ssn_rejects_zero_serial() {
2534 assert_eq!(parse_us_ssn("123-45-0000"), None);
2535 }
2536
2537 #[test]
2538 fn us_ssn_rejects_wrong_length() {
2539 assert_eq!(parse_us_ssn("12345"), None);
2540 assert_eq!(parse_us_ssn("1234567890"), None);
2541 assert_eq!(parse_us_ssn(""), None);
2542 }
2543
2544 #[test]
2545 fn us_ssn_rejects_letters() {
2546 assert_eq!(parse_us_ssn("ABC-DE-FGHI"), None);
2547 assert_eq!(parse_us_ssn("ABCDEFGHI"), None);
2548 }
2549
2550 #[test]
2551 fn us_ssn_strips_arbitrary_punctuation() {
2552 assert_eq!(parse_us_ssn("(123).45.6789"), Some("123456789".into()),);
2553 }
2554
2555 // ---------- parse_de_kvnr ----------
2556
2557 #[test]
2558 fn de_kvnr_canonical_form_parses() {
2559 assert_eq!(parse_de_kvnr("A123456780"), Some("A123456780".into()));
2560 }
2561
2562 #[test]
2563 fn de_kvnr_accepts_lowercase_letter_canonicalises_to_upper() {
2564 assert_eq!(parse_de_kvnr("a123456780"), Some("A123456780".into()));
2565 }
2566
2567 #[test]
2568 fn de_kvnr_accepts_internal_whitespace() {
2569 assert_eq!(parse_de_kvnr("A 123 456 780"), Some("A123456780".into()));
2570 }
2571
2572 #[test]
2573 fn de_kvnr_second_valid_vector() {
2574 assert_eq!(parse_de_kvnr("B987654320"), Some("B987654320".into()));
2575 }
2576
2577 #[test]
2578 fn de_kvnr_rejects_wrong_check_digit() {
2579 assert_eq!(parse_de_kvnr("A123456789"), None);
2580 }
2581
2582 #[test]
2583 fn de_kvnr_rejects_missing_letter() {
2584 assert_eq!(parse_de_kvnr("1234567890"), None);
2585 }
2586
2587 #[test]
2588 fn de_kvnr_rejects_wrong_length() {
2589 assert_eq!(parse_de_kvnr("A12345"), None);
2590 assert_eq!(parse_de_kvnr("A1234567890"), None);
2591 assert_eq!(parse_de_kvnr(""), None);
2592 }
2593
2594 #[test]
2595 fn de_kvnr_rejects_non_digit_in_body() {
2596 assert_eq!(parse_de_kvnr("A12345A780"), None);
2597 }
2598
2599 // ---------- parse_it_cf ----------
2600
2601 #[test]
2602 fn it_cf_canonical_form_parses() {
2603 assert_eq!(
2604 parse_it_cf("RSSMRA85T10A562S"),
2605 Some("RSSMRA85T10A562S".into()),
2606 );
2607 }
2608
2609 #[test]
2610 fn it_cf_accepts_lowercase_and_whitespace() {
2611 assert_eq!(
2612 parse_it_cf("rss mra 85t 10a 562s"),
2613 Some("RSSMRA85T10A562S".into()),
2614 );
2615 }
2616
2617 #[test]
2618 fn it_cf_second_valid_vector() {
2619 assert_eq!(
2620 parse_it_cf("MNRMRC75H17H501I"),
2621 Some("MNRMRC75H17H501I".into()),
2622 );
2623 }
2624
2625 #[test]
2626 fn it_cf_rejects_wrong_check_character() {
2627 assert_eq!(parse_it_cf("RSSMRA85T10A562X"), None);
2628 }
2629
2630 #[test]
2631 fn it_cf_rejects_wrong_length() {
2632 assert_eq!(parse_it_cf("RSSMRA85T10A562"), None);
2633 assert_eq!(parse_it_cf("RSSMRA85T10A562SS"), None);
2634 assert_eq!(parse_it_cf(""), None);
2635 }
2636
2637 #[test]
2638 fn it_cf_rejects_non_alphanumeric() {
2639 assert_eq!(parse_it_cf("RSSMRA85T10A562!"), None);
2640 assert_eq!(parse_it_cf("RSSMRA-85T-10A562S"), None);
2641 }
2642
2643 // ---------- parse_nl_bsn ----------
2644
2645 #[test]
2646 fn nl_bsn_canonical_form_parses() {
2647 assert_eq!(parse_nl_bsn("111222333"), Some("111222333".into()));
2648 }
2649
2650 #[test]
2651 fn nl_bsn_second_valid_vector() {
2652 assert_eq!(parse_nl_bsn("123456782"), Some("123456782".into()));
2653 }
2654
2655 #[test]
2656 fn nl_bsn_strips_separators() {
2657 assert_eq!(parse_nl_bsn("111 222 333"), Some("111222333".into()));
2658 assert_eq!(parse_nl_bsn("111-222-333"), Some("111222333".into()));
2659 }
2660
2661 #[test]
2662 fn nl_bsn_rejects_wrong_eleven_test() {
2663 assert_eq!(parse_nl_bsn("111222334"), None);
2664 }
2665
2666 #[test]
2667 fn nl_bsn_rejects_all_zeros() {
2668 assert_eq!(parse_nl_bsn("000000000"), None);
2669 }
2670
2671 #[test]
2672 fn nl_bsn_rejects_wrong_length() {
2673 assert_eq!(parse_nl_bsn("12345"), None);
2674 assert_eq!(parse_nl_bsn("1234567890"), None);
2675 assert_eq!(parse_nl_bsn(""), None);
2676 }
2677
2678 #[test]
2679 fn nl_bsn_rejects_letters() {
2680 assert_eq!(parse_nl_bsn("ABCDEFGHI"), None);
2681 }
2682
2683 // ---------- parse_se_workernummer ----------
2684
2685 #[test]
2686 fn se_pnr_ten_digit_form_parses() {
2687 assert_eq!(
2688 parse_se_workernummer("4603243850"),
2689 Some("4603243850".into()),
2690 );
2691 }
2692
2693 #[test]
2694 fn se_pnr_with_separator_canonicalises_to_ten_digit() {
2695 assert_eq!(
2696 parse_se_workernummer("460324-3850"),
2697 Some("4603243850".into()),
2698 );
2699 assert_eq!(
2700 parse_se_workernummer("460324+3850"),
2701 Some("4603243850".into()),
2702 );
2703 }
2704
2705 #[test]
2706 fn se_pnr_twelve_digit_form_preserves_century() {
2707 assert_eq!(
2708 parse_se_workernummer("19460324-3850"),
2709 Some("194603243850".into()),
2710 );
2711 assert_eq!(
2712 parse_se_workernummer("194603243850"),
2713 Some("194603243850".into()),
2714 );
2715 }
2716
2717 #[test]
2718 fn se_pnr_second_valid_vector() {
2719 assert_eq!(
2720 parse_se_workernummer("8112310092"),
2721 Some("8112310092".into()),
2722 );
2723 }
2724
2725 #[test]
2726 fn se_pnr_rejects_wrong_luhn() {
2727 assert_eq!(parse_se_workernummer("4603243851"), None);
2728 }
2729
2730 #[test]
2731 fn se_pnr_rejects_wrong_length() {
2732 assert_eq!(parse_se_workernummer("12345"), None);
2733 assert_eq!(parse_se_workernummer("12345678901"), None);
2734 assert_eq!(parse_se_workernummer(""), None);
2735 }
2736
2737 #[test]
2738 fn se_pnr_rejects_letters() {
2739 assert_eq!(parse_se_workernummer("ABCDEFGHIJ"), None);
2740 }
2741
2742 // ---------- parse_au_ihi ----------
2743
2744 #[test]
2745 fn au_ihi_canonical_form_parses() {
2746 assert_eq!(
2747 parse_au_ihi("8003601234567894"),
2748 Some("8003601234567894".into()),
2749 );
2750 }
2751
2752 #[test]
2753 fn au_ihi_strips_whitespace() {
2754 assert_eq!(
2755 parse_au_ihi("8003 6012 3456 7894"),
2756 Some("8003601234567894".into()),
2757 );
2758 }
2759
2760 #[test]
2761 fn au_ihi_second_valid_vector() {
2762 assert_eq!(
2763 parse_au_ihi("8003619876543213"),
2764 Some("8003619876543213".into()),
2765 );
2766 }
2767
2768 #[test]
2769 fn au_ihi_rejects_wrong_luhn() {
2770 assert_eq!(parse_au_ihi("8003601234567890"), None);
2771 }
2772
2773 #[test]
2774 fn au_ihi_rejects_wrong_length() {
2775 assert_eq!(parse_au_ihi("12345"), None);
2776 assert_eq!(parse_au_ihi("80036012345678941"), None);
2777 assert_eq!(parse_au_ihi(""), None);
2778 }
2779
2780 #[test]
2781 fn au_ihi_rejects_letters() {
2782 assert_eq!(parse_au_ihi("ABCDEFGHIJKLMNOP"), None);
2783 }
2784
2785 // ---------- parse_uk_chi_number ----------
2786
2787 #[test]
2788 fn uk_chi_canonical_form_parses() {
2789 assert_eq!(parse_uk_chi_number("0101701233"), Some("0101701233".into()),);
2790 }
2791
2792 #[test]
2793 fn uk_chi_strips_whitespace() {
2794 assert_eq!(
2795 parse_uk_chi_number("010 170 1233"),
2796 Some("0101701233".into()),
2797 );
2798 }
2799
2800 #[test]
2801 fn uk_chi_second_valid_vector() {
2802 assert_eq!(parse_uk_chi_number("0101701241"), Some("0101701241".into()),);
2803 }
2804
2805 #[test]
2806 fn uk_chi_rejects_wrong_check_digit() {
2807 assert_eq!(parse_uk_chi_number("0101701234"), None);
2808 }
2809
2810 #[test]
2811 fn uk_chi_rejects_wrong_length() {
2812 assert_eq!(parse_uk_chi_number("12345"), None);
2813 assert_eq!(parse_uk_chi_number("01017012339"), None);
2814 assert_eq!(parse_uk_chi_number(""), None);
2815 }
2816
2817 #[test]
2818 fn uk_chi_rejects_letters() {
2819 assert_eq!(parse_uk_chi_number("ABCDEFGHIJ"), None);
2820 }
2821
2822 // ----------------------------------------------------------------------
2823 // Eighteen additional national workeral identifiers (T-27).
2824 // ----------------------------------------------------------------------
2825
2826 // ---------- parse_be_nn ----------
2827
2828 #[test]
2829 fn be_nn_canonical_form_parses() {
2830 assert_eq!(parse_be_nn("80010100107"), Some("80010100107".into()));
2831 }
2832 #[test]
2833 fn be_nn_strips_punctuation() {
2834 assert_eq!(parse_be_nn("80.01.01-001.07"), Some("80010100107".into()),);
2835 }
2836 #[test]
2837 fn be_nn_rejects_wrong_check() {
2838 assert_eq!(parse_be_nn("80010100100"), None);
2839 }
2840 #[test]
2841 fn be_nn_rejects_wrong_length() {
2842 assert_eq!(parse_be_nn("12345"), None);
2843 assert_eq!(parse_be_nn(""), None);
2844 }
2845
2846 // ---------- parse_bg_egn ----------
2847
2848 #[test]
2849 fn bg_egn_canonical_form_parses() {
2850 assert_eq!(parse_bg_egn("8001010013"), Some("8001010013".into()));
2851 }
2852 #[test]
2853 fn bg_egn_rejects_wrong_check() {
2854 assert_eq!(parse_bg_egn("8001010014"), None);
2855 }
2856 #[test]
2857 fn bg_egn_rejects_wrong_length() {
2858 assert_eq!(parse_bg_egn("80010100"), None);
2859 assert_eq!(parse_bg_egn(""), None);
2860 }
2861
2862 // ---------- parse_cz_rc ----------
2863
2864 #[test]
2865 fn cz_rc_ten_digit_divisible_by_eleven() {
2866 assert_eq!(parse_cz_rc("8001150014"), Some("8001150014".into()));
2867 }
2868 #[test]
2869 fn cz_rc_nine_digit_pre_1954_accepted_as_is() {
2870 assert_eq!(parse_cz_rc("800115001"), Some("800115001".into()));
2871 }
2872 #[test]
2873 fn cz_rc_rejects_wrong_check() {
2874 assert_eq!(parse_cz_rc("8001150015"), None);
2875 }
2876 #[test]
2877 fn cz_rc_rejects_bad_length() {
2878 assert_eq!(parse_cz_rc("12345"), None);
2879 assert_eq!(parse_cz_rc("12345678901"), None);
2880 }
2881
2882 // ---------- parse_dk_cpr ----------
2883
2884 #[test]
2885 fn dk_cpr_canonical_parses() {
2886 assert_eq!(parse_dk_cpr("1501801234"), Some("1501801234".into()));
2887 }
2888 #[test]
2889 fn dk_cpr_strips_separator() {
2890 assert_eq!(parse_dk_cpr("150180-1234"), Some("1501801234".into()));
2891 }
2892 #[test]
2893 fn dk_cpr_rejects_bad_length() {
2894 assert_eq!(parse_dk_cpr("12345"), None);
2895 assert_eq!(parse_dk_cpr(""), None);
2896 }
2897
2898 // ---------- parse_ee_ik ----------
2899
2900 #[test]
2901 fn ee_ik_canonical_form_parses() {
2902 assert_eq!(parse_ee_ik("48001150011"), Some("48001150011".into()));
2903 }
2904 #[test]
2905 fn ee_ik_rejects_wrong_check() {
2906 assert_eq!(parse_ee_ik("48001150012"), None);
2907 }
2908 #[test]
2909 fn ee_ik_rejects_bad_length() {
2910 assert_eq!(parse_ee_ik("4800115001"), None);
2911 }
2912
2913 // ---------- parse_es_dni ----------
2914
2915 #[test]
2916 fn es_dni_canonical_form_parses() {
2917 assert_eq!(parse_es_dni("12345678Z"), Some("12345678Z".into()));
2918 }
2919 #[test]
2920 fn es_dni_rejects_wrong_letter() {
2921 assert_eq!(parse_es_dni("12345678A"), None);
2922 }
2923 #[test]
2924 fn es_dni_lowercase_letter_canonicalises_upper() {
2925 assert_eq!(parse_es_dni("12345678z"), Some("12345678Z".into()));
2926 }
2927 #[test]
2928 fn es_dni_handles_nie_prefix_x() {
2929 // NIE X1234567L → number is "01234567" mod 23 = (01234567 % 23).
2930 // 1234567 mod 23: 23 × 53676 = 1234548. 1234567 - 1234548 = 19.
2931 // LETTERS[19] = 'L'. So "X1234567L" is valid.
2932 assert_eq!(parse_es_dni("X1234567L"), Some("X1234567L".into()));
2933 }
2934
2935 // ---------- parse_fi_hetu ----------
2936
2937 #[test]
2938 fn fi_hetu_canonical_form_parses() {
2939 assert_eq!(parse_fi_hetu("150180-999B"), Some("150180-999B".into()));
2940 }
2941 #[test]
2942 fn fi_hetu_rejects_wrong_check() {
2943 assert_eq!(parse_fi_hetu("150180-999C"), None);
2944 }
2945 #[test]
2946 fn fi_hetu_rejects_bad_length() {
2947 assert_eq!(parse_fi_hetu("12345"), None);
2948 }
2949
2950 // ---------- parse_hr_oib ----------
2951
2952 #[test]
2953 fn hr_oib_canonical_form_parses() {
2954 assert_eq!(parse_hr_oib("12345678903"), Some("12345678903".into()));
2955 }
2956 #[test]
2957 fn hr_oib_rejects_wrong_check() {
2958 assert_eq!(parse_hr_oib("12345678901"), None);
2959 }
2960 #[test]
2961 fn hr_oib_rejects_bad_length() {
2962 assert_eq!(parse_hr_oib("123456789"), None);
2963 }
2964
2965 // ---------- parse_is_kt ----------
2966
2967 #[test]
2968 fn is_kt_canonical_form_parses() {
2969 assert_eq!(parse_is_kt("1501802529"), Some("1501802529".into()));
2970 }
2971 #[test]
2972 fn is_kt_rejects_wrong_check() {
2973 assert_eq!(parse_is_kt("1501802539"), None);
2974 }
2975 #[test]
2976 fn is_kt_rejects_bad_length() {
2977 assert_eq!(parse_is_kt("12345"), None);
2978 }
2979
2980 // ---------- parse_lt_ak ----------
2981
2982 #[test]
2983 fn lt_ak_canonical_form_parses() {
2984 assert_eq!(parse_lt_ak("48001150011"), Some("48001150011".into()));
2985 }
2986 #[test]
2987 fn lt_ak_rejects_wrong_check() {
2988 assert_eq!(parse_lt_ak("48001150012"), None);
2989 }
2990
2991 // ---------- parse_lv_pk ----------
2992
2993 #[test]
2994 fn lv_pk_canonical_form_parses() {
2995 assert_eq!(parse_lv_pk("15018010007"), Some("15018010007".into()));
2996 }
2997 #[test]
2998 fn lv_pk_rejects_wrong_check() {
2999 assert_eq!(parse_lv_pk("15018010008"), None);
3000 }
3001 #[test]
3002 fn lv_pk_rejects_bad_length() {
3003 assert_eq!(parse_lv_pk("1501801000"), None);
3004 }
3005
3006 // ---------- parse_mt_id ----------
3007
3008 #[test]
3009 fn mt_id_canonical_form_parses() {
3010 assert_eq!(parse_mt_id("1234567M"), Some("1234567M".into()));
3011 }
3012 #[test]
3013 fn mt_id_accepts_all_valid_letters() {
3014 for letter in ['M', 'G', 'A', 'P', 'L', 'H', 'B', 'Z'] {
3015 let s = format!("1234567{letter}");
3016 assert!(parse_mt_id(&s).is_some(), "letter {letter} should be valid");
3017 }
3018 }
3019 #[test]
3020 fn mt_id_rejects_invalid_letter() {
3021 assert_eq!(parse_mt_id("1234567X"), None);
3022 assert_eq!(parse_mt_id("1234567K"), None);
3023 }
3024 #[test]
3025 fn mt_id_rejects_bad_length() {
3026 assert_eq!(parse_mt_id("12345M"), None);
3027 }
3028
3029 // ---------- parse_no_fnr ----------
3030
3031 #[test]
3032 fn no_fnr_canonical_form_parses() {
3033 assert_eq!(parse_no_fnr("15018012399"), Some("15018012399".into()));
3034 }
3035 #[test]
3036 fn no_fnr_rejects_wrong_check() {
3037 assert_eq!(parse_no_fnr("15018012390"), None);
3038 assert_eq!(parse_no_fnr("15018012398"), None);
3039 }
3040 #[test]
3041 fn no_fnr_rejects_bad_length() {
3042 assert_eq!(parse_no_fnr("12345"), None);
3043 }
3044
3045 // ---------- parse_pl_pesel ----------
3046
3047 #[test]
3048 fn pl_pesel_canonical_form_parses() {
3049 assert_eq!(parse_pl_pesel("80011500014"), Some("80011500014".into()));
3050 }
3051 #[test]
3052 fn pl_pesel_rejects_wrong_check() {
3053 assert_eq!(parse_pl_pesel("80011500015"), None);
3054 }
3055 #[test]
3056 fn pl_pesel_rejects_bad_length() {
3057 assert_eq!(parse_pl_pesel("1234"), None);
3058 }
3059
3060 // ---------- parse_ro_cnp ----------
3061
3062 #[test]
3063 fn ro_cnp_canonical_form_parses() {
3064 assert_eq!(parse_ro_cnp("1800115400012"), Some("1800115400012".into()));
3065 }
3066 #[test]
3067 fn ro_cnp_rejects_wrong_check() {
3068 assert_eq!(parse_ro_cnp("1800115400015"), None);
3069 }
3070 #[test]
3071 fn ro_cnp_rejects_bad_length() {
3072 assert_eq!(parse_ro_cnp("180011540001"), None);
3073 }
3074
3075 // ---------- parse_si_emso ----------
3076
3077 #[test]
3078 fn si_emso_canonical_form_parses() {
3079 assert_eq!(parse_si_emso("1501980500015"), Some("1501980500015".into()));
3080 }
3081 #[test]
3082 fn si_emso_rejects_wrong_check() {
3083 assert_eq!(parse_si_emso("1501980500014"), None);
3084 }
3085
3086 // ---------- parse_sk_rc ----------
3087
3088 #[test]
3089 fn sk_rc_canonical_form_parses() {
3090 assert_eq!(parse_sk_rc("8051150019"), Some("8051150019".into()));
3091 }
3092 #[test]
3093 fn sk_rc_rejects_wrong_check() {
3094 assert_eq!(parse_sk_rc("8051150010"), None);
3095 }
3096
3097 // ---------- parse_uk_nino ----------
3098
3099 #[test]
3100 fn uk_nino_canonical_form_parses() {
3101 assert_eq!(parse_uk_nino("AB123456A"), Some("AB123456A".into()));
3102 }
3103 #[test]
3104 fn uk_nino_accepts_lowercase_and_whitespace() {
3105 assert_eq!(parse_uk_nino("ab 12 34 56 a"), Some("AB123456A".into()),);
3106 }
3107 #[test]
3108 fn uk_nino_rejects_banned_first_letter() {
3109 for ch in ['D', 'F', 'I', 'Q', 'U', 'V'] {
3110 let s = format!("{ch}A123456A");
3111 assert!(parse_uk_nino(&s).is_none(), "letter {ch} should be banned");
3112 }
3113 }
3114 #[test]
3115 fn uk_nino_rejects_banned_admin_prefix() {
3116 for prefix in ["OO", "CR", "FY", "MW", "NC", "PP", "PZ", "TN"] {
3117 let s = format!("{prefix}123456A");
3118 assert!(
3119 parse_uk_nino(&s).is_none(),
3120 "prefix {prefix} should be banned"
3121 );
3122 }
3123 }
3124 #[test]
3125 fn uk_nino_rejects_bad_suffix() {
3126 for ch in ['E', 'F', 'X', 'Z'] {
3127 let s = format!("AB123456{ch}");
3128 assert!(parse_uk_nino(&s).is_none(), "suffix {ch} should be invalid");
3129 }
3130 }
3131 #[test]
3132 fn uk_nino_rejects_bad_length() {
3133 assert_eq!(parse_uk_nino("AB12345A"), None);
3134 }
3135
3136 // ----------------------------------------------------------------------
3137 // T-28: Five additional workeral identifiers.
3138 // ----------------------------------------------------------------------
3139
3140 // ---------- parse_gr_dss ----------
3141
3142 #[test]
3143 fn gr_dss_canonical_form_parses() {
3144 assert_eq!(parse_gr_dss("1234567890"), Some("1234567890".into()));
3145 }
3146 #[test]
3147 fn gr_dss_strips_punctuation() {
3148 assert_eq!(parse_gr_dss("12 34-56 78 90"), Some("1234567890".into()));
3149 }
3150 #[test]
3151 fn gr_dss_rejects_bad_length() {
3152 assert_eq!(parse_gr_dss("12345"), None);
3153 assert_eq!(parse_gr_dss("12345678901"), None);
3154 assert_eq!(parse_gr_dss(""), None);
3155 }
3156 #[test]
3157 fn gr_dss_rejects_letters() {
3158 assert_eq!(parse_gr_dss("ABCDEFGHIJ"), None);
3159 }
3160
3161 // ---------- parse_li_id ----------
3162
3163 #[test]
3164 fn li_id_eight_digit_form_parses() {
3165 assert_eq!(parse_li_id("ID12345678"), Some("ID12345678".into()));
3166 }
3167 #[test]
3168 fn li_id_nine_digit_example_from_spec_parses() {
3169 assert_eq!(parse_li_id("ID022143586"), Some("ID022143586".into()));
3170 }
3171 #[test]
3172 fn li_id_lowercase_letters_uppercased() {
3173 assert_eq!(parse_li_id("id12345678"), Some("ID12345678".into()));
3174 }
3175 #[test]
3176 fn li_id_rejects_missing_letters() {
3177 assert_eq!(parse_li_id("1234567890"), None);
3178 assert_eq!(parse_li_id("I12345678"), None); // only one leading letter
3179 }
3180 #[test]
3181 fn li_id_rejects_bad_length() {
3182 assert_eq!(parse_li_id(""), None);
3183 assert_eq!(parse_li_id("ID1234"), None);
3184 assert_eq!(parse_li_id("ID123456789012"), None);
3185 }
3186
3187 // ---------- parse_nl_id ----------
3188
3189 #[test]
3190 fn nl_id_canonical_form_parses() {
3191 assert_eq!(parse_nl_id("AB1234567"), Some("AB1234567".into()));
3192 }
3193 #[test]
3194 fn nl_id_lowercase_and_whitespace_canonicalise() {
3195 assert_eq!(parse_nl_id("ab 12 34 567"), Some("AB1234567".into()));
3196 }
3197 #[test]
3198 fn nl_id_rejects_letter_o_in_disallowed_positions() {
3199 assert_eq!(parse_nl_id("AO1234567"), None);
3200 assert_eq!(parse_nl_id("OB1234567"), None);
3201 assert_eq!(parse_nl_id("ABO234567"), None);
3202 }
3203 #[test]
3204 fn nl_id_allows_digit_zero() {
3205 assert_eq!(parse_nl_id("AB0234567"), Some("AB0234567".into()));
3206 }
3207 #[test]
3208 fn nl_id_rejects_bad_shape() {
3209 assert_eq!(parse_nl_id("12345AB67"), None);
3210 assert_eq!(parse_nl_id("AB12345AB"), None);
3211 assert_eq!(parse_nl_id(""), None);
3212 }
3213
3214 // ---------- parse_pl_nip ----------
3215
3216 #[test]
3217 fn pl_nip_canonical_form_parses() {
3218 assert_eq!(parse_pl_nip("1234567802"), Some("1234567802".into()));
3219 }
3220 #[test]
3221 fn pl_nip_strips_separators() {
3222 assert_eq!(parse_pl_nip("123-456-78-02"), Some("1234567802".into()));
3223 }
3224 #[test]
3225 fn pl_nip_rejects_wrong_check() {
3226 assert_eq!(parse_pl_nip("1234567803"), None);
3227 }
3228 #[test]
3229 fn pl_nip_rejects_check_value_ten_per_spec() {
3230 // For "123456789" body the weighted sum mod 11 is 10, which the
3231 // Polish NIP spec defines as invalid.
3232 assert_eq!(parse_pl_nip("1234567890"), None);
3233 }
3234 #[test]
3235 fn pl_nip_rejects_bad_length() {
3236 assert_eq!(parse_pl_nip("12345"), None);
3237 }
3238
3239 // ---------- parse_pt_nif ----------
3240
3241 #[test]
3242 fn pt_nif_canonical_form_parses() {
3243 assert_eq!(parse_pt_nif("123456789"), Some("123456789".into()));
3244 }
3245 #[test]
3246 fn pt_nif_rejects_wrong_check() {
3247 assert_eq!(parse_pt_nif("123456780"), None);
3248 }
3249 #[test]
3250 fn pt_nif_rejects_bad_length() {
3251 assert_eq!(parse_pt_nif("12345"), None);
3252 assert_eq!(parse_pt_nif("1234567890"), None);
3253 }
3254
3255 // ----------------------------------------------------------------------
3256 // T-17.1: Seven next-batch national identifier schemes.
3257 // ----------------------------------------------------------------------
3258
3259 // ---------- parse_br_cpf ----------
3260 #[test]
3261 fn br_cpf_canonical_form_parses() {
3262 assert_eq!(parse_br_cpf("12345678909"), Some("12345678909".into()));
3263 }
3264 #[test]
3265 fn br_cpf_formatted_input_strips_punctuation() {
3266 assert_eq!(parse_br_cpf("123.456.789-09"), Some("12345678909".into()));
3267 }
3268 #[test]
3269 fn br_cpf_rejects_wrong_check() {
3270 assert_eq!(parse_br_cpf("12345678900"), None);
3271 }
3272 #[test]
3273 fn br_cpf_rejects_all_equal_sequences() {
3274 for d in '0'..='9' {
3275 let s: String = std::iter::repeat_n(d, 11).collect();
3276 assert_eq!(parse_br_cpf(&s), None, "{s}");
3277 }
3278 }
3279 #[test]
3280 fn br_cpf_rejects_bad_length() {
3281 assert_eq!(parse_br_cpf("1234567890"), None);
3282 assert_eq!(parse_br_cpf("123456789090"), None);
3283 }
3284 #[test]
3285 fn br_cpf_rejects_non_digit_only_input() {
3286 assert_eq!(parse_br_cpf("abcdefghijk"), None);
3287 }
3288
3289 // ---------- parse_cn_rrn ----------
3290 #[test]
3291 fn cn_rrn_canonical_form_parses() {
3292 assert_eq!(
3293 parse_cn_rrn("11010519491231002X"),
3294 Some("11010519491231002X".into()),
3295 );
3296 }
3297 #[test]
3298 fn cn_rrn_uppercases_lowercase_x() {
3299 assert_eq!(
3300 parse_cn_rrn("11010519491231002x"),
3301 Some("11010519491231002X".into()),
3302 );
3303 }
3304 #[test]
3305 fn cn_rrn_rejects_wrong_check_char() {
3306 assert_eq!(parse_cn_rrn("11010519491231002Y"), None);
3307 assert_eq!(parse_cn_rrn("110105194912310020"), None);
3308 }
3309 #[test]
3310 fn cn_rrn_rejects_invalid_date_substring() {
3311 assert_eq!(parse_cn_rrn("11010513491231002X"), None);
3312 assert_eq!(parse_cn_rrn("110105194913320002X"), None);
3313 }
3314 #[test]
3315 fn cn_rrn_rejects_bad_length() {
3316 assert_eq!(parse_cn_rrn("11010519491231"), None);
3317 assert_eq!(parse_cn_rrn("11010519491231002XY"), None);
3318 }
3319 #[test]
3320 fn cn_rrn_rejects_non_alnum_letters() {
3321 // A non-X non-digit at the check position is rejected.
3322 assert_eq!(parse_cn_rrn("11010519491231002A"), None);
3323 }
3324
3325 // ---------- parse_in_aadhaar ----------
3326 #[test]
3327 fn in_aadhaar_canonical_form_parses() {
3328 assert_eq!(
3329 parse_in_aadhaar("234123412346"),
3330 Some("234123412346".into())
3331 );
3332 }
3333 #[test]
3334 fn in_aadhaar_strips_whitespace() {
3335 assert_eq!(
3336 parse_in_aadhaar("2341 2341 2346"),
3337 Some("234123412346".into()),
3338 );
3339 }
3340 #[test]
3341 fn in_aadhaar_rejects_wrong_verhoeff_check() {
3342 assert_eq!(parse_in_aadhaar("234123412347"), None);
3343 assert_eq!(parse_in_aadhaar("234123412345"), None);
3344 }
3345 #[test]
3346 fn in_aadhaar_rejects_all_equal_sequences() {
3347 for d in '2'..='9' {
3348 let s: String = std::iter::repeat_n(d, 12).collect();
3349 assert_eq!(parse_in_aadhaar(&s), None, "{s}");
3350 }
3351 }
3352 #[test]
3353 fn in_aadhaar_rejects_reserved_prefixes() {
3354 // UIDAI never issues numbers starting with 0 or 1.
3355 assert_eq!(parse_in_aadhaar("034123412346"), None);
3356 assert_eq!(parse_in_aadhaar("134123412346"), None);
3357 }
3358 #[test]
3359 fn in_aadhaar_rejects_bad_length() {
3360 assert_eq!(parse_in_aadhaar("234123412"), None);
3361 assert_eq!(parse_in_aadhaar("2341234123466"), None);
3362 }
3363
3364 // ---------- parse_jp_my_number ----------
3365 #[test]
3366 fn jp_my_number_canonical_form_parses() {
3367 assert_eq!(
3368 parse_jp_my_number("123456789018"),
3369 Some("123456789018".into()),
3370 );
3371 }
3372 #[test]
3373 fn jp_my_number_strips_whitespace() {
3374 assert_eq!(
3375 parse_jp_my_number("1234 5678 9018"),
3376 Some("123456789018".into()),
3377 );
3378 }
3379 #[test]
3380 fn jp_my_number_rejects_wrong_check() {
3381 assert_eq!(parse_jp_my_number("123456789010"), None);
3382 assert_eq!(parse_jp_my_number("123456789019"), None);
3383 }
3384 #[test]
3385 fn jp_my_number_rejects_bad_length() {
3386 assert_eq!(parse_jp_my_number("12345678901"), None);
3387 assert_eq!(parse_jp_my_number("1234567890123"), None);
3388 }
3389 #[test]
3390 fn jp_my_number_rejects_non_digit_only_input() {
3391 assert_eq!(parse_jp_my_number("abcdefghijkl"), None);
3392 }
3393
3394 // ---------- parse_mx_curp ----------
3395 #[test]
3396 fn mx_curp_canonical_form_parses() {
3397 assert_eq!(
3398 parse_mx_curp("HEGG560427MVZRRL04"),
3399 Some("HEGG560427MVZRRL04".into()),
3400 );
3401 }
3402 #[test]
3403 fn mx_curp_uppercases_input() {
3404 assert_eq!(
3405 parse_mx_curp("hegg560427mvzrrl04"),
3406 Some("HEGG560427MVZRRL04".into()),
3407 );
3408 }
3409 #[test]
3410 fn mx_curp_rejects_wrong_check() {
3411 assert_eq!(parse_mx_curp("HEGG560427MVZRRL05"), None);
3412 }
3413 #[test]
3414 fn mx_curp_rejects_invalid_date_substring() {
3415 assert_eq!(parse_mx_curp("HEGG561327MVZRRL04"), None);
3416 assert_eq!(parse_mx_curp("HEGG569927MVZRRL04"), None);
3417 }
3418 #[test]
3419 fn mx_curp_rejects_bad_sex_char() {
3420 assert_eq!(parse_mx_curp("HEGG560427XVZRRL04"), None);
3421 }
3422 #[test]
3423 fn mx_curp_rejects_bad_length() {
3424 assert_eq!(parse_mx_curp("HEGG560427"), None);
3425 assert_eq!(parse_mx_curp("HEGG560427MVZRRL04EXTRA"), None);
3426 }
3427
3428 // ---------- parse_nz_nhi ----------
3429 #[test]
3430 fn nz_nhi_canonical_form_parses() {
3431 assert_eq!(parse_nz_nhi("ZAA0083"), Some("ZAA0083".into()));
3432 }
3433 #[test]
3434 fn nz_nhi_uppercases_input() {
3435 assert_eq!(parse_nz_nhi("zaa0083"), Some("ZAA0083".into()));
3436 }
3437 #[test]
3438 fn nz_nhi_rejects_wrong_check() {
3439 assert_eq!(parse_nz_nhi("ZAA0082"), None);
3440 }
3441 #[test]
3442 fn nz_nhi_rejects_excluded_letters_i_and_o() {
3443 assert_eq!(parse_nz_nhi("ZAI0083"), None);
3444 assert_eq!(parse_nz_nhi("ZAO0083"), None);
3445 assert_eq!(parse_nz_nhi("IZA0083"), None);
3446 }
3447 #[test]
3448 fn nz_nhi_rejects_bad_length() {
3449 assert_eq!(parse_nz_nhi("ZAA008"), None);
3450 assert_eq!(parse_nz_nhi("ZAA00830"), None);
3451 }
3452 #[test]
3453 fn nz_nhi_rejects_non_letter_prefix() {
3454 assert_eq!(parse_nz_nhi("Z1A0083"), None);
3455 }
3456
3457 // ---------- parse_za_id ----------
3458 #[test]
3459 fn za_id_canonical_form_parses() {
3460 assert_eq!(parse_za_id("8001015009087"), Some("8001015009087".into()));
3461 }
3462 #[test]
3463 fn za_id_strips_whitespace() {
3464 assert_eq!(parse_za_id("800101 5009 087"), Some("8001015009087".into()),);
3465 }
3466 #[test]
3467 fn za_id_rejects_wrong_luhn() {
3468 assert_eq!(parse_za_id("8001015009088"), None);
3469 assert_eq!(parse_za_id("8001015009086"), None);
3470 }
3471 #[test]
3472 fn za_id_rejects_invalid_date_substring() {
3473 assert_eq!(parse_za_id("8013015009087"), None);
3474 assert_eq!(parse_za_id("8002305009087"), None);
3475 }
3476 #[test]
3477 fn za_id_rejects_bad_length() {
3478 assert_eq!(parse_za_id("80010150090"), None);
3479 assert_eq!(parse_za_id("80010150090870"), None);
3480 }
3481
3482 // ----------------------------------------------------------------------
3483 // T-28: Nine per-country passport-number format validators.
3484 // ----------------------------------------------------------------------
3485
3486 #[test]
3487 fn cy_passport_pre_2010_form_parses() {
3488 assert_eq!(parse_cy_passport("E123456"), Some("E123456".into()));
3489 }
3490 #[test]
3491 fn cy_passport_biometric_form_parses() {
3492 assert_eq!(parse_cy_passport("K12345678"), Some("K12345678".into()));
3493 }
3494 #[test]
3495 fn cy_passport_rejects_wrong_prefix() {
3496 assert_eq!(parse_cy_passport("A123456"), None);
3497 assert_eq!(parse_cy_passport("Z12345678"), None);
3498 }
3499 #[test]
3500 fn cy_passport_rejects_bad_length() {
3501 assert_eq!(parse_cy_passport("E12345"), None);
3502 assert_eq!(parse_cy_passport("K1234567"), None);
3503 }
3504
3505 #[test]
3506 fn cz_passport_eight_digit_form_parses() {
3507 assert_eq!(parse_cz_passport("12345678"), Some("12345678".into()));
3508 }
3509 #[test]
3510 fn cz_passport_accepts_longer_forms() {
3511 assert_eq!(
3512 parse_cz_passport("123456789012"),
3513 Some("123456789012".into())
3514 );
3515 }
3516 #[test]
3517 fn cz_passport_rejects_short_forms() {
3518 assert_eq!(parse_cz_passport("1234567"), None);
3519 assert_eq!(parse_cz_passport(""), None);
3520 }
3521
3522 #[test]
3523 fn li_passport_canonical_form_parses() {
3524 assert_eq!(parse_li_passport("R00536"), Some("R00536".into()));
3525 }
3526 #[test]
3527 fn li_passport_lowercases_to_upper() {
3528 assert_eq!(parse_li_passport("r00536"), Some("R00536".into()));
3529 }
3530 #[test]
3531 fn li_passport_rejects_bad_format() {
3532 assert_eq!(parse_li_passport("RR0536"), None);
3533 assert_eq!(parse_li_passport("123456"), None);
3534 }
3535
3536 #[test]
3537 fn lt_passport_eight_digit_parses() {
3538 assert_eq!(parse_lt_passport("12345678"), Some("12345678".into()));
3539 }
3540 #[test]
3541 fn lt_passport_rejects_wrong_length() {
3542 assert_eq!(parse_lt_passport("1234567"), None);
3543 assert_eq!(parse_lt_passport("123456789"), None);
3544 }
3545
3546 #[test]
3547 fn mt_passport_seven_digit_parses() {
3548 assert_eq!(parse_mt_passport("1234567"), Some("1234567".into()));
3549 }
3550 #[test]
3551 fn mt_passport_rejects_letters() {
3552 assert_eq!(parse_mt_passport("123456M"), None);
3553 }
3554
3555 #[test]
3556 fn nl_passport_uses_nl_id_format() {
3557 assert_eq!(parse_nl_passport("AB1234567"), Some("AB1234567".into()));
3558 assert_eq!(parse_nl_passport("AO1234567"), None);
3559 }
3560
3561 #[test]
3562 fn pt_passport_canonical_form_parses() {
3563 assert_eq!(parse_pt_passport("A123456"), Some("A123456".into()));
3564 }
3565 #[test]
3566 fn pt_passport_rejects_bad_shape() {
3567 assert_eq!(parse_pt_passport("AA12345"), None);
3568 assert_eq!(parse_pt_passport("1234567"), None);
3569 }
3570
3571 #[test]
3572 fn ro_passport_canonical_form_parses() {
3573 assert_eq!(parse_ro_passport("AB123456"), Some("AB123456".into()));
3574 }
3575 #[test]
3576 fn ro_passport_rejects_bad_shape() {
3577 assert_eq!(parse_ro_passport("A1234567"), None);
3578 assert_eq!(parse_ro_passport("ABC12345"), None);
3579 }
3580
3581 #[test]
3582 fn sk_passport_canonical_form_parses() {
3583 assert_eq!(parse_sk_passport("AB1234567"), Some("AB1234567".into()));
3584 }
3585 #[test]
3586 fn sk_passport_rejects_bad_shape() {
3587 assert_eq!(parse_sk_passport("ABC123456"), None);
3588 assert_eq!(parse_sk_passport("AB12345"), None);
3589 }
3590}