use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Gender {
Male,
Female,
Other,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BloodType {
#[serde(rename = "A+")]
APositive,
#[serde(rename = "A-")]
ANegative,
#[serde(rename = "B+")]
BPositive,
#[serde(rename = "B-")]
BNegative,
#[serde(rename = "AB+")]
ABPositive,
#[serde(rename = "AB-")]
ABNegative,
#[serde(rename = "O+")]
OPositive,
#[serde(rename = "O-")]
ONegative,
}
impl BloodType {
pub fn as_str(&self) -> &'static str {
match self {
BloodType::APositive => "A+",
BloodType::ANegative => "A-",
BloodType::BPositive => "B+",
BloodType::BNegative => "B-",
BloodType::ABPositive => "AB+",
BloodType::ABNegative => "AB-",
BloodType::OPositive => "O+",
BloodType::ONegative => "O-",
}
}
pub fn parse(s: &str) -> Option<BloodType> {
let upper: String = s
.trim()
.to_uppercase()
.chars()
.map(|c| if c == '0' { 'O' } else { c })
.collect();
if upper.is_empty() {
return None;
}
let (group, rest): (&str, &str) = if let Some(r) = upper.strip_prefix("AB") {
("AB", r)
} else if let Some(r) = upper.strip_prefix('A') {
("A", r)
} else if let Some(r) = upper.strip_prefix('B') {
("B", r)
} else if let Some(r) = upper.strip_prefix('O') {
("O", r)
} else {
return None;
};
let positive = parse_rh_sign(rest)?;
Some(match (group, positive) {
("A", true) => BloodType::APositive,
("A", false) => BloodType::ANegative,
("B", true) => BloodType::BPositive,
("B", false) => BloodType::BNegative,
("AB", true) => BloodType::ABPositive,
("AB", false) => BloodType::ABNegative,
("O", true) => BloodType::OPositive,
("O", false) => BloodType::ONegative,
_ => return None,
})
}
}
impl std::fmt::Display for BloodType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn parse_rh_sign(s: &str) -> Option<bool> {
let trimmed = s.trim_start_matches([' ', '\t', '_', '/']).trim();
if trimmed.is_empty() {
return None;
}
let word_candidate = trimmed.trim_start_matches(['-', '+']).trim();
if word_candidate.starts_with("POSITIVE")
|| word_candidate.starts_with("POS")
|| word_candidate == "P"
{
return Some(true);
}
if word_candidate.starts_with("NEGATIVE")
|| word_candidate.starts_with("NEG")
|| word_candidate == "N"
{
return Some(false);
}
if let Some(after) = trimmed.strip_prefix('+') {
let tail = after.trim().trim_start_matches("VE");
if tail.trim().is_empty() {
return Some(true);
}
return None;
}
if let Some(after) = trimmed.strip_prefix('-') {
let tail = after.trim().trim_start_matches("VE");
if tail.trim().is_empty() {
return Some(false);
}
return None;
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Address {
pub line1: Option<String>,
pub line2: Option<String>,
pub city: Option<String>,
pub county: Option<String>,
pub postcode: Option<String>,
pub country: Option<String>,
}
impl Address {
pub fn new() -> Self {
Self {
line1: None,
line2: None,
city: None,
county: None,
postcode: None,
country: None,
}
}
pub fn with_line1(mut self, value: impl Into<String>) -> Self {
self.line1 = Some(value.into());
self
}
pub fn with_line2(mut self, value: impl Into<String>) -> Self {
self.line2 = Some(value.into());
self
}
pub fn with_city(mut self, value: impl Into<String>) -> Self {
self.city = Some(value.into());
self
}
pub fn with_county(mut self, value: impl Into<String>) -> Self {
self.county = Some(value.into());
self
}
pub fn with_postcode(mut self, value: impl Into<String>) -> Self {
self.postcode = Some(value.into());
self
}
pub fn with_country(mut self, value: impl Into<String>) -> Self {
self.country = Some(value.into());
self
}
}
impl Default for Address {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PassportBook {
pub country: String,
pub number: String,
#[serde(default)]
pub issued: Option<NaiveDate>,
#[serde(default)]
pub expires: Option<NaiveDate>,
}
impl PassportBook {
pub fn new(country: impl AsRef<str>, number: impl AsRef<str>) -> Option<Self> {
let country = country.as_ref().trim().to_ascii_uppercase();
if country.len() != 2 || !country.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
let number: String = number
.as_ref()
.chars()
.filter(|c| !c.is_whitespace() && !matches!(*c, '-' | '.' | '/'))
.collect::<String>()
.to_uppercase();
if number.is_empty() {
return None;
}
Some(Self {
country,
number,
issued: None,
expires: None,
})
}
pub fn with_issued(mut self, date: NaiveDate) -> Self {
self.issued = Some(date);
self
}
pub fn with_expires(mut self, date: NaiveDate) -> Self {
self.expires = Some(date);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Worker {
#[serde(default)]
pub uk_nhs_number: Option<String>,
#[serde(default)]
pub fr_nir: Option<String>,
#[serde(default)]
pub es_tsi: Option<String>,
#[serde(default)]
pub ie_ihi: Option<String>,
#[serde(default)]
pub uk_hc_number: Option<String>,
#[serde(default)]
pub us_ssn: Option<String>,
#[serde(default)]
pub au_ihi: Option<String>,
#[serde(default)]
pub de_kvnr: Option<String>,
#[serde(default)]
pub it_cf: Option<String>,
#[serde(default)]
pub nl_bsn: Option<String>,
#[serde(default)]
pub se_personnummer: Option<String>,
#[serde(default)]
pub uk_chi_number: Option<String>,
#[serde(default)]
pub be_nn: Option<String>,
#[serde(default)]
pub bg_egn: Option<String>,
#[serde(default)]
pub cz_rc: Option<String>,
#[serde(default)]
pub dk_cpr: Option<String>,
#[serde(default)]
pub ee_ik: Option<String>,
#[serde(default)]
pub es_dni: Option<String>,
#[serde(default)]
pub fi_hetu: Option<String>,
#[serde(default)]
pub hr_oib: Option<String>,
#[serde(default)]
pub is_kt: Option<String>,
#[serde(default)]
pub lt_ak: Option<String>,
#[serde(default)]
pub lv_pk: Option<String>,
#[serde(default)]
pub mt_id: Option<String>,
#[serde(default)]
pub no_fnr: Option<String>,
#[serde(default)]
pub pl_pesel: Option<String>,
#[serde(default)]
pub ro_cnp: Option<String>,
#[serde(default)]
pub si_emso: Option<String>,
#[serde(default)]
pub sk_rc: Option<String>,
#[serde(default)]
pub uk_nino: Option<String>,
#[serde(default)]
pub gr_dss: Option<String>,
#[serde(default)]
pub li_id: Option<String>,
#[serde(default)]
pub nl_id: Option<String>,
#[serde(default)]
pub pl_nip: Option<String>,
#[serde(default)]
pub pt_nif: Option<String>,
#[serde(default)]
pub br_cpf: Option<String>,
#[serde(default)]
pub cn_rrn: Option<String>,
#[serde(default)]
pub in_aadhaar: Option<String>,
#[serde(default)]
pub jp_my_number: Option<String>,
#[serde(default)]
pub mx_curp: Option<String>,
#[serde(default)]
pub nz_nhi: Option<String>,
#[serde(default)]
pub za_id: Option<String>,
pub given_name: Option<String>,
pub middle_name: Option<String>,
pub family_name: Option<String>,
pub date_of_birth: Option<NaiveDate>,
#[serde(default)]
pub death_date: Option<NaiveDate>,
pub gender: Option<Gender>,
#[serde(default)]
pub blood_type: Option<BloodType>,
#[serde(default)]
pub multiple_birth: Option<u8>,
pub address: Option<Address>,
#[serde(default)]
pub birth_place: Option<Address>,
#[serde(default)]
pub death_place: Option<Address>,
pub previous_addresses: Vec<Address>,
#[serde(default)]
pub passport_books: Vec<PassportBook>,
pub phone: Option<String>,
pub mobile: Option<String>,
pub email: Option<String>,
pub local_id: Option<String>,
}
impl Worker {
pub fn builder() -> WorkerBuilder {
WorkerBuilder::default()
}
pub fn validate(&self) -> crate::Result<()> {
let has_name = self.given_name.is_some() || self.family_name.is_some();
let has_identifier = self.uk_nhs_number.is_some()
|| self.fr_nir.is_some()
|| self.es_tsi.is_some()
|| self.ie_ihi.is_some()
|| self.uk_hc_number.is_some()
|| self.us_ssn.is_some()
|| self.au_ihi.is_some()
|| self.de_kvnr.is_some()
|| self.it_cf.is_some()
|| self.nl_bsn.is_some()
|| self.se_personnummer.is_some()
|| self.uk_chi_number.is_some()
|| self.be_nn.is_some()
|| self.bg_egn.is_some()
|| self.cz_rc.is_some()
|| self.dk_cpr.is_some()
|| self.ee_ik.is_some()
|| self.es_dni.is_some()
|| self.fi_hetu.is_some()
|| self.hr_oib.is_some()
|| self.is_kt.is_some()
|| self.lt_ak.is_some()
|| self.lv_pk.is_some()
|| self.mt_id.is_some()
|| self.no_fnr.is_some()
|| self.pl_pesel.is_some()
|| self.ro_cnp.is_some()
|| self.si_emso.is_some()
|| self.sk_rc.is_some()
|| self.uk_nino.is_some()
|| self.gr_dss.is_some()
|| self.li_id.is_some()
|| self.nl_id.is_some()
|| self.pl_nip.is_some()
|| self.pt_nif.is_some()
|| self.br_cpf.is_some()
|| self.cn_rrn.is_some()
|| self.in_aadhaar.is_some()
|| self.jp_my_number.is_some()
|| self.mx_curp.is_some()
|| self.nz_nhi.is_some()
|| self.za_id.is_some()
|| !self.passport_books.is_empty();
if !has_name && !has_identifier {
return Err(crate::MatchingError::MissingField(
"At least one of: a name, a national identifier (any of 30 supported schemes), or at least one passport book is required"
.to_string(),
));
}
Ok(())
}
}
#[derive(Default)]
pub struct WorkerBuilder {
uk_nhs_number: Option<String>,
fr_nir: Option<String>,
es_tsi: Option<String>,
ie_ihi: Option<String>,
uk_hc_number: Option<String>,
us_ssn: Option<String>,
au_ihi: Option<String>,
de_kvnr: Option<String>,
it_cf: Option<String>,
nl_bsn: Option<String>,
se_personnummer: Option<String>,
uk_chi_number: Option<String>,
be_nn: Option<String>,
bg_egn: Option<String>,
cz_rc: Option<String>,
dk_cpr: Option<String>,
ee_ik: Option<String>,
es_dni: Option<String>,
fi_hetu: Option<String>,
hr_oib: Option<String>,
is_kt: Option<String>,
lt_ak: Option<String>,
lv_pk: Option<String>,
mt_id: Option<String>,
no_fnr: Option<String>,
pl_pesel: Option<String>,
ro_cnp: Option<String>,
si_emso: Option<String>,
sk_rc: Option<String>,
uk_nino: Option<String>,
gr_dss: Option<String>,
li_id: Option<String>,
nl_id: Option<String>,
pl_nip: Option<String>,
pt_nif: Option<String>,
br_cpf: Option<String>,
cn_rrn: Option<String>,
in_aadhaar: Option<String>,
jp_my_number: Option<String>,
mx_curp: Option<String>,
nz_nhi: Option<String>,
za_id: Option<String>,
given_name: Option<String>,
middle_name: Option<String>,
family_name: Option<String>,
date_of_birth: Option<NaiveDate>,
death_date: Option<NaiveDate>,
gender: Option<Gender>,
blood_type: Option<BloodType>,
multiple_birth: Option<u8>,
address: Option<Address>,
birth_place: Option<Address>,
death_place: Option<Address>,
previous_addresses: Vec<Address>,
passport_books: Vec<PassportBook>,
phone: Option<String>,
mobile: Option<String>,
email: Option<String>,
local_id: Option<String>,
}
impl WorkerBuilder {
pub fn uk_nhs_number<S: Into<String>>(mut self, value: S) -> Self {
self.uk_nhs_number = Some(value.into());
self
}
pub fn fr_nir<S: Into<String>>(mut self, value: S) -> Self {
self.fr_nir = Some(value.into());
self
}
pub fn es_tsi<S: Into<String>>(mut self, value: S) -> Self {
self.es_tsi = Some(value.into());
self
}
pub fn ie_ihi<S: Into<String>>(mut self, value: S) -> Self {
self.ie_ihi = Some(value.into());
self
}
pub fn uk_hc_number<S: Into<String>>(mut self, value: S) -> Self {
self.uk_hc_number = Some(value.into());
self
}
pub fn us_ssn<S: Into<String>>(mut self, value: S) -> Self {
self.us_ssn = Some(value.into());
self
}
pub fn au_ihi<S: Into<String>>(mut self, value: S) -> Self {
self.au_ihi = Some(value.into());
self
}
pub fn de_kvnr<S: Into<String>>(mut self, value: S) -> Self {
self.de_kvnr = Some(value.into());
self
}
pub fn it_cf<S: Into<String>>(mut self, value: S) -> Self {
self.it_cf = Some(value.into());
self
}
pub fn nl_bsn<S: Into<String>>(mut self, value: S) -> Self {
self.nl_bsn = Some(value.into());
self
}
pub fn se_personnummer<S: Into<String>>(mut self, value: S) -> Self {
self.se_personnummer = Some(value.into());
self
}
pub fn uk_chi_number<S: Into<String>>(mut self, value: S) -> Self {
self.uk_chi_number = Some(value.into());
self
}
pub fn be_nn<S: Into<String>>(mut self, value: S) -> Self {
self.be_nn = Some(value.into());
self
}
pub fn bg_egn<S: Into<String>>(mut self, value: S) -> Self {
self.bg_egn = Some(value.into());
self
}
pub fn cz_rc<S: Into<String>>(mut self, value: S) -> Self {
self.cz_rc = Some(value.into());
self
}
pub fn dk_cpr<S: Into<String>>(mut self, value: S) -> Self {
self.dk_cpr = Some(value.into());
self
}
pub fn ee_ik<S: Into<String>>(mut self, value: S) -> Self {
self.ee_ik = Some(value.into());
self
}
pub fn es_dni<S: Into<String>>(mut self, value: S) -> Self {
self.es_dni = Some(value.into());
self
}
pub fn fi_hetu<S: Into<String>>(mut self, value: S) -> Self {
self.fi_hetu = Some(value.into());
self
}
pub fn hr_oib<S: Into<String>>(mut self, value: S) -> Self {
self.hr_oib = Some(value.into());
self
}
pub fn is_kt<S: Into<String>>(mut self, value: S) -> Self {
self.is_kt = Some(value.into());
self
}
pub fn lt_ak<S: Into<String>>(mut self, value: S) -> Self {
self.lt_ak = Some(value.into());
self
}
pub fn lv_pk<S: Into<String>>(mut self, value: S) -> Self {
self.lv_pk = Some(value.into());
self
}
pub fn mt_id<S: Into<String>>(mut self, value: S) -> Self {
self.mt_id = Some(value.into());
self
}
pub fn no_fnr<S: Into<String>>(mut self, value: S) -> Self {
self.no_fnr = Some(value.into());
self
}
pub fn pl_pesel<S: Into<String>>(mut self, value: S) -> Self {
self.pl_pesel = Some(value.into());
self
}
pub fn ro_cnp<S: Into<String>>(mut self, value: S) -> Self {
self.ro_cnp = Some(value.into());
self
}
pub fn si_emso<S: Into<String>>(mut self, value: S) -> Self {
self.si_emso = Some(value.into());
self
}
pub fn sk_rc<S: Into<String>>(mut self, value: S) -> Self {
self.sk_rc = Some(value.into());
self
}
pub fn uk_nino<S: Into<String>>(mut self, value: S) -> Self {
self.uk_nino = Some(value.into());
self
}
pub fn gr_dss<S: Into<String>>(mut self, value: S) -> Self {
self.gr_dss = Some(value.into());
self
}
pub fn li_id<S: Into<String>>(mut self, value: S) -> Self {
self.li_id = Some(value.into());
self
}
pub fn nl_id<S: Into<String>>(mut self, value: S) -> Self {
self.nl_id = Some(value.into());
self
}
pub fn pl_nip<S: Into<String>>(mut self, value: S) -> Self {
self.pl_nip = Some(value.into());
self
}
pub fn pt_nif<S: Into<String>>(mut self, value: S) -> Self {
self.pt_nif = Some(value.into());
self
}
pub fn br_cpf<S: Into<String>>(mut self, value: S) -> Self {
self.br_cpf = Some(value.into());
self
}
pub fn cn_rrn<S: Into<String>>(mut self, value: S) -> Self {
self.cn_rrn = Some(value.into());
self
}
pub fn in_aadhaar<S: Into<String>>(mut self, value: S) -> Self {
self.in_aadhaar = Some(value.into());
self
}
pub fn jp_my_number<S: Into<String>>(mut self, value: S) -> Self {
self.jp_my_number = Some(value.into());
self
}
pub fn mx_curp<S: Into<String>>(mut self, value: S) -> Self {
self.mx_curp = Some(value.into());
self
}
pub fn nz_nhi<S: Into<String>>(mut self, value: S) -> Self {
self.nz_nhi = Some(value.into());
self
}
pub fn za_id<S: Into<String>>(mut self, value: S) -> Self {
self.za_id = Some(value.into());
self
}
pub fn given_name<S: Into<String>>(mut self, value: S) -> Self {
self.given_name = Some(value.into());
self
}
pub fn middle_name<S: Into<String>>(mut self, value: S) -> Self {
self.middle_name = Some(value.into());
self
}
pub fn family_name<S: Into<String>>(mut self, value: S) -> Self {
self.family_name = Some(value.into());
self
}
pub fn date_of_birth(mut self, value: NaiveDate) -> Self {
self.date_of_birth = Some(value);
self
}
pub fn death_date(mut self, value: NaiveDate) -> Self {
self.death_date = Some(value);
self
}
pub fn gender(mut self, value: Gender) -> Self {
self.gender = Some(value);
self
}
pub fn blood_type(mut self, value: BloodType) -> Self {
self.blood_type = Some(value);
self
}
pub fn multiple_birth(mut self, value: u8) -> Self {
self.multiple_birth = Some(value);
self
}
pub fn address(mut self, value: Address) -> Self {
self.address = Some(value);
self
}
pub fn birth_place(mut self, value: Address) -> Self {
self.birth_place = Some(value);
self
}
pub fn death_place(mut self, value: Address) -> Self {
self.death_place = Some(value);
self
}
pub fn previous_addresses(mut self, value: Vec<Address>) -> Self {
self.previous_addresses = value;
self
}
pub fn add_passport_book(mut self, book: PassportBook) -> Self {
self.passport_books.push(book);
self
}
pub fn passport_books(mut self, value: Vec<PassportBook>) -> Self {
self.passport_books = value;
self
}
pub fn phone<S: Into<String>>(mut self, value: S) -> Self {
self.phone = Some(value.into());
self
}
pub fn mobile<S: Into<String>>(mut self, value: S) -> Self {
self.mobile = Some(value.into());
self
}
pub fn email<S: Into<String>>(mut self, value: S) -> Self {
self.email = Some(value.into());
self
}
pub fn local_id<S: Into<String>>(mut self, value: S) -> Self {
self.local_id = Some(value.into());
self
}
pub fn build(self) -> Worker {
Worker {
uk_nhs_number: self.uk_nhs_number,
fr_nir: self.fr_nir,
es_tsi: self.es_tsi,
ie_ihi: self.ie_ihi,
uk_hc_number: self.uk_hc_number,
us_ssn: self.us_ssn,
au_ihi: self.au_ihi,
de_kvnr: self.de_kvnr,
it_cf: self.it_cf,
nl_bsn: self.nl_bsn,
se_personnummer: self.se_personnummer,
uk_chi_number: self.uk_chi_number,
be_nn: self.be_nn,
bg_egn: self.bg_egn,
cz_rc: self.cz_rc,
dk_cpr: self.dk_cpr,
ee_ik: self.ee_ik,
es_dni: self.es_dni,
fi_hetu: self.fi_hetu,
hr_oib: self.hr_oib,
is_kt: self.is_kt,
lt_ak: self.lt_ak,
lv_pk: self.lv_pk,
mt_id: self.mt_id,
no_fnr: self.no_fnr,
pl_pesel: self.pl_pesel,
ro_cnp: self.ro_cnp,
si_emso: self.si_emso,
sk_rc: self.sk_rc,
uk_nino: self.uk_nino,
gr_dss: self.gr_dss,
li_id: self.li_id,
nl_id: self.nl_id,
pl_nip: self.pl_nip,
pt_nif: self.pt_nif,
br_cpf: self.br_cpf,
cn_rrn: self.cn_rrn,
in_aadhaar: self.in_aadhaar,
jp_my_number: self.jp_my_number,
mx_curp: self.mx_curp,
nz_nhi: self.nz_nhi,
za_id: self.za_id,
given_name: self.given_name,
middle_name: self.middle_name,
family_name: self.family_name,
date_of_birth: self.date_of_birth,
death_date: self.death_date,
gender: self.gender,
blood_type: self.blood_type,
multiple_birth: self.multiple_birth,
address: self.address,
birth_place: self.birth_place,
death_place: self.death_place,
previous_addresses: self.previous_addresses,
passport_books: self.passport_books,
phone: self.phone,
mobile: self.mobile,
email: self.email,
local_id: self.local_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn address_new_is_all_none() {
let a = Address::new();
assert!(a.line1.is_none());
assert!(a.line2.is_none());
assert!(a.city.is_none());
assert!(a.county.is_none());
assert!(a.postcode.is_none());
assert!(a.country.is_none());
}
#[test]
fn address_default_matches_new() {
assert_eq!(Address::default(), Address::new());
}
#[test]
fn address_fluent_builders_chain() {
let a = Address::new()
.with_line1("10 Downing Street")
.with_city("London")
.with_postcode("SW1A 2AA")
.with_country("United Kingdom");
assert_eq!(a.line1.as_deref(), Some("10 Downing Street"));
assert_eq!(a.city.as_deref(), Some("London"));
assert_eq!(a.postcode.as_deref(), Some("SW1A 2AA"));
assert_eq!(a.country.as_deref(), Some("United Kingdom"));
assert!(a.line2.is_none());
assert!(a.county.is_none());
}
#[test]
fn address_round_trips_through_serde() {
let mut a = Address::new();
a.line1 = Some("123 High Street".into());
a.postcode = Some("CF10 1AA".into());
let json = serde_json::to_string(&a).expect("serialise");
let back: Address = serde_json::from_str(&json).expect("deserialise");
assert_eq!(a, back);
}
#[test]
fn worker_builder_starts_empty() {
let p = Worker::builder().build();
assert!(p.uk_nhs_number.is_none());
assert!(p.fr_nir.is_none());
assert!(p.es_tsi.is_none());
assert!(p.ie_ihi.is_none());
assert!(p.uk_hc_number.is_none());
assert!(p.us_ssn.is_none());
assert!(p.au_ihi.is_none());
assert!(p.de_kvnr.is_none());
assert!(p.it_cf.is_none());
assert!(p.nl_bsn.is_none());
assert!(p.se_personnummer.is_none());
assert!(p.uk_chi_number.is_none());
assert!(p.given_name.is_none());
assert!(p.family_name.is_none());
assert!(p.date_of_birth.is_none());
assert!(p.gender.is_none());
assert!(p.address.is_none());
assert!(p.previous_addresses.is_empty());
assert!(p.passport_books.is_empty());
assert!(p.phone.is_none());
assert!(p.mobile.is_none());
assert!(p.email.is_none());
assert!(p.local_id.is_none());
}
#[test]
fn worker_builder_carries_all_national_identifiers() {
let p = Worker::builder()
.uk_nhs_number("9434765919")
.fr_nir("180127512345642")
.es_tsi("ABCD123456XY1234")
.ie_ihi("1234567")
.uk_hc_number("9434765919")
.us_ssn("123-45-6789")
.au_ihi("8003601234567894")
.de_kvnr("A123456780")
.it_cf("RSSMRA85T10A562S")
.nl_bsn("111222333")
.se_personnummer("4603243850")
.uk_chi_number("0101701233")
.build();
assert_eq!(p.uk_nhs_number.as_deref(), Some("9434765919"));
assert_eq!(p.fr_nir.as_deref(), Some("180127512345642"));
assert_eq!(p.es_tsi.as_deref(), Some("ABCD123456XY1234"));
assert_eq!(p.ie_ihi.as_deref(), Some("1234567"));
assert_eq!(p.uk_hc_number.as_deref(), Some("9434765919"));
assert_eq!(p.us_ssn.as_deref(), Some("123-45-6789"));
assert_eq!(p.au_ihi.as_deref(), Some("8003601234567894"));
assert_eq!(p.de_kvnr.as_deref(), Some("A123456780"));
assert_eq!(p.it_cf.as_deref(), Some("RSSMRA85T10A562S"));
assert_eq!(p.nl_bsn.as_deref(), Some("111222333"));
assert_eq!(p.se_personnummer.as_deref(), Some("4603243850"));
assert_eq!(p.uk_chi_number.as_deref(), Some("0101701233"));
}
#[test]
fn worker_builder_accepts_str_and_string() {
let p = Worker::builder()
.given_name("Owen") .family_name(String::from("Jones")) .build();
assert_eq!(p.given_name.as_deref(), Some("Owen"));
assert_eq!(p.family_name.as_deref(), Some("Jones"));
}
#[test]
fn worker_validate_requires_one_of_three_fields() {
assert!(Worker::builder().given_name("a").build().validate().is_ok());
assert!(
Worker::builder()
.family_name("a")
.build()
.validate()
.is_ok()
);
assert!(
Worker::builder()
.uk_nhs_number("9434765919")
.build()
.validate()
.is_ok()
);
let err = Worker::builder()
.build()
.validate()
.expect_err("should be missing");
assert!(matches!(err, crate::MatchingError::MissingField(_)));
}
#[test]
fn worker_round_trips_through_serde() {
let p = Worker::builder()
.uk_nhs_number("9434765919")
.given_name("Carys")
.family_name("Pritchard")
.date_of_birth(chrono::NaiveDate::from_ymd_opt(1990, 6, 1).unwrap())
.gender(Gender::Female)
.build();
let json = serde_json::to_string(&p).expect("serialise");
let back: Worker = serde_json::from_str(&json).expect("deserialise");
assert_eq!(p, back);
}
#[test]
fn blood_type_parses_canonical_short_forms() {
for (s, want) in [
("A+", BloodType::APositive),
("A-", BloodType::ANegative),
("B+", BloodType::BPositive),
("B-", BloodType::BNegative),
("AB+", BloodType::ABPositive),
("AB-", BloodType::ABNegative),
("O+", BloodType::OPositive),
("O-", BloodType::ONegative),
] {
assert_eq!(BloodType::parse(s), Some(want), "parse {s:?}");
}
}
#[test]
fn blood_type_parses_lowercase_and_whitespace() {
assert_eq!(BloodType::parse(" a+ "), Some(BloodType::APositive));
assert_eq!(BloodType::parse("ab-"), Some(BloodType::ABNegative));
}
#[test]
fn blood_type_parses_word_forms() {
assert_eq!(BloodType::parse("A positive"), Some(BloodType::APositive));
assert_eq!(BloodType::parse("A pos"), Some(BloodType::APositive));
assert_eq!(BloodType::parse("A POS"), Some(BloodType::APositive));
assert_eq!(BloodType::parse("A negative"), Some(BloodType::ANegative));
assert_eq!(BloodType::parse("ab neg"), Some(BloodType::ABNegative));
assert_eq!(BloodType::parse("o NEG"), Some(BloodType::ONegative));
}
#[test]
fn blood_type_parses_zero_as_o() {
assert_eq!(BloodType::parse("0+"), Some(BloodType::OPositive));
assert_eq!(BloodType::parse("0-"), Some(BloodType::ONegative));
}
#[test]
fn blood_type_parses_with_separator() {
assert_eq!(BloodType::parse("A_pos"), Some(BloodType::APositive));
assert_eq!(BloodType::parse("A-neg"), Some(BloodType::ANegative));
assert_eq!(BloodType::parse("AB +"), Some(BloodType::ABPositive));
}
#[test]
fn blood_type_parses_ve_suffix() {
assert_eq!(BloodType::parse("A+VE"), Some(BloodType::APositive));
assert_eq!(BloodType::parse("a-ve"), Some(BloodType::ANegative));
}
#[test]
fn blood_type_rejects_unparseable() {
assert_eq!(BloodType::parse(""), None);
assert_eq!(BloodType::parse(" "), None);
assert_eq!(BloodType::parse("Z+"), None);
assert_eq!(BloodType::parse("A"), None); assert_eq!(BloodType::parse("Bombay"), None);
assert_eq!(BloodType::parse("A++"), None);
}
#[test]
fn blood_type_as_str_and_display_round_trip() {
for bt in [
BloodType::APositive,
BloodType::ANegative,
BloodType::BPositive,
BloodType::BNegative,
BloodType::ABPositive,
BloodType::ABNegative,
BloodType::OPositive,
BloodType::ONegative,
] {
let s = bt.as_str();
assert_eq!(format!("{bt}"), s);
assert_eq!(BloodType::parse(s), Some(bt));
}
}
#[test]
fn blood_type_serde_uses_short_form() {
for (bt, json) in [
(BloodType::APositive, "\"A+\""),
(BloodType::ABNegative, "\"AB-\""),
(BloodType::ONegative, "\"O-\""),
] {
assert_eq!(serde_json::to_string(&bt).unwrap(), json);
let back: BloodType = serde_json::from_str(json).unwrap();
assert_eq!(back, bt);
}
}
#[test]
fn worker_builder_sets_blood_type() {
let p = Worker::builder().blood_type(BloodType::OPositive).build();
assert_eq!(p.blood_type, Some(BloodType::OPositive));
}
#[test]
fn worker_default_has_no_blood_type() {
let p = Worker::builder().build();
assert!(p.blood_type.is_none());
}
#[test]
fn gender_is_copy_and_eq() {
let g = Gender::Female;
let h = g; assert_eq!(g, h);
assert_ne!(g, Gender::Male);
}
#[test]
fn passport_book_new_canonicalises_country_and_number() {
let b = PassportBook::new(" gb ", " 123 ABC 789 ").unwrap();
assert_eq!(b.country, "GB");
assert_eq!(b.number, "123ABC789");
}
#[test]
fn passport_book_new_strips_common_separators() {
let b = PassportBook::new("GB", "ABC-123/456.789").unwrap();
assert_eq!(b.number, "ABC123456789");
let c = PassportBook::new("US", "AB-12-34-567").unwrap();
assert_eq!(c.number, "AB1234567");
}
#[test]
fn passport_book_new_rejects_bad_country() {
assert!(PassportBook::new("GBR", "123").is_none()); assert!(PassportBook::new("G", "123").is_none()); assert!(PassportBook::new("1A", "123").is_none()); assert!(PassportBook::new("", "123").is_none()); }
#[test]
fn passport_book_new_rejects_empty_number() {
assert!(PassportBook::new("GB", "").is_none());
assert!(PassportBook::new("GB", " ").is_none());
assert!(PassportBook::new("GB", "\t\n").is_none());
}
#[test]
fn passport_book_with_dates_sets_metadata() {
let b = PassportBook::new("GB", "123")
.unwrap()
.with_issued(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
.with_expires(chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
assert!(b.issued.is_some());
assert!(b.expires.is_some());
}
#[test]
fn passport_book_round_trips_through_serde() {
let b = PassportBook::new("US", "AB1234567")
.unwrap()
.with_issued(chrono::NaiveDate::from_ymd_opt(2024, 6, 1).unwrap());
let json = serde_json::to_string(&b).unwrap();
let back: PassportBook = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
}
#[test]
fn passport_book_serde_default_dates() {
let legacy = r#"{"country": "GB", "number": "123"}"#;
let b: PassportBook = serde_json::from_str(legacy).unwrap();
assert_eq!(b.country, "GB");
assert_eq!(b.number, "123");
assert!(b.issued.is_none());
assert!(b.expires.is_none());
}
#[test]
fn worker_builder_carries_passport_books() {
let p = Worker::builder()
.add_passport_book(PassportBook::new("GB", "111").unwrap())
.add_passport_book(PassportBook::new("US", "222").unwrap())
.build();
assert_eq!(p.passport_books.len(), 2);
assert_eq!(p.passport_books[0].country, "GB");
assert_eq!(p.passport_books[1].country, "US");
}
#[test]
fn worker_validate_accepts_solo_passport_book() {
let p = Worker::builder()
.add_passport_book(PassportBook::new("GB", "123456789").unwrap())
.build();
assert!(p.validate().is_ok());
}
#[test]
fn previous_addresses_setter_replaces_vec() {
let mut a = Address::new();
a.postcode = Some("CF10 1AA".into());
let p = Worker::builder()
.previous_addresses(vec![a.clone()])
.build();
assert_eq!(p.previous_addresses, vec![a]);
}
}