diff --git a/Cargo.toml b/Cargo.toml index 3ceef6d..b99addd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,10 @@ include = ["src/**/*.rs", "Cargo.toml", "LICENSE", "README.md"] [dependencies] -urlencoding = { version = "2.1.3", optional = true } regex = "1.10.2" +urlencoding = { version = "2.1.3", optional = true } serde = { version = "1.0.193", features = ["derive"], optional = true } +thiserror = {version = "1.0.48", optional = true} # Edit `Makefile` and `src/lib.src` after making changes in this section: @@ -44,7 +45,7 @@ commas = [] digits = [] find-capital-by-province = ["to-persian-chars"] is-persian = [] -national-id = [] +national-id = ["dep:thiserror"] remove-ordinal-suffix = [] to-persian-chars = [] url-fix = ["dep:urlencoding"] @@ -55,3 +56,7 @@ number-to-words = [] [package.metadata.docs.rs] all-features = true + +[dev-dependencies] +# To test `serde` feature expectaions: +serde_json = "1.0.107" diff --git a/Makefile b/Makefile index b8ee017..9ffd4b4 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ build: full default add-ordinal-suffix commas digits find-capital-by-province is check: clippy lint test: - cargo test --all-features -- --nocapture + RUST_BACKTRACE=1 cargo test --all-features -- --nocapture docs: cargo doc --all-features @@ -61,6 +61,9 @@ national-id: @ echo "" cargo build --no-default-features --features=national-id @ ls -sh target/debug/*.rlib + cargo build --no-default-features --features="national-id serde" + @ ls -sh target/debug/*.rlib + remove-ordinal-suffix: @ echo "" diff --git a/src/national_id/mod.rs b/src/national_id/mod.rs index a2b7fd7..ff90fac 100644 --- a/src/national_id/mod.rs +++ b/src/national_id/mod.rs @@ -1,66 +1,145 @@ -/// Validation of Iranian National Number(code-e Melli) -pub fn verify_iranian_national_id(code: S) -> bool -where - S: Into, -{ - let code: String = code.into(); +//! Iranian National Number utils (`national-id` Cargo feature). +//! +//! #### Example +//! ```rust +//! use rust_persian_tools::national_id::{NationalIdError, verify_iranian_national_id}; +//! +//! assert!(verify_iranian_national_id("11537027").is_ok()); +//! assert!(verify_iranian_national_id("0076229645").is_ok()); +//! assert!(verify_iranian_national_id("1583250689").is_ok()); +//! +//! assert_eq!( +//! verify_iranian_national_id("12345"), +//! Err(NationalIdError::Length(5)), +//! ); +//! assert_eq!( +//! verify_iranian_national_id("9999999999"), +//! Err(NationalIdError::Invalid) +//! ); +//! ``` +//! +//! #### [serde] Integration +//! ##### National Number +//! ```rust +//! use rust_persian_tools::national_id::serde::national_id_de; +//! +//! #[derive(Debug, PartialEq, serde::Deserialize)] +//! struct MyStruct { +//! #[serde(deserialize_with = "national_id_de")] +//! id: String, +//! } +//! +//! let json_str = "{\"id\": \"0076229645\"}"; +//! let my_struct: MyStruct = serde_json::from_str(json_str).unwrap(); +//! assert_eq!(my_struct, MyStruct{id: "0076229645".to_string()}); +//! +//! let json_str_invalid = "{\"id\": \"ZeroOneTwo\"}"; +//! assert!(serde_json::from_str::(json_str_invalid).is_err()); +//! assert_eq!( +//! serde_json::from_str::(json_str_invalid).err().unwrap().to_string(), +//! "Could not convert National Number to numeric at line 1 column 19".to_string(), +//! ); +//! ``` +//! ##### Option\ +//! ```rust +//! use rust_persian_tools::national_id::serde::national_id_option_de; +//! +//! #[derive(Debug, PartialEq, serde::Deserialize)] +//! struct MyStruct { +//! #[serde(default, deserialize_with = "national_id_option_de")] +//! id: Option, +//! } +//! +//! let json_str = "{}"; +//! let my_struct: MyStruct = serde_json::from_str(json_str).unwrap(); +//! assert_eq!(my_struct, MyStruct{id: None}); +//! let json_str = "{\"id\": \"0076229645\"}"; +//! let my_struct: MyStruct = serde_json::from_str(json_str).unwrap(); +//! assert_eq!(my_struct, MyStruct{id: Some("0076229645".to_string())}); +//! ``` - let code_length = code.len(); - if !(8..=10).contains(&code_length) { - return false; - } +use std::num::ParseIntError; - if code.parse::().is_err() { - return false; - } +#[cfg(feature = "serde")] +pub mod serde; - if code == "0000000000" { - return false; - } - if code == "1111111111" { - return false; - } - if code == "2222222222" { - return false; - } - if code == "3333333333" { - return false; - } - if code == "4444444444" { - return false; - } - if code == "5555555555" { - return false; - } - if code == "6666666666" { - return false; - } - if code == "7777777777" { - return false; - } - if code == "8888888888" { - return false; - } - if code == "9999999999" { - return false; - } +/// Possible errors during validation of Iranian National Number. +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum NationalIdError { + /// If input length is invalid. + #[error("Invalid length {0} for National Number")] + Length(usize), + /// If input is not a [u64] number. + #[error("Could not convert National Number to numeric")] + NumericConvert { source: ParseIntError }, + /// Other checks. + #[error("National Number is invalid")] + Invalid, +} + +/// Validation of Iranian National Number (code-e Melli). +/// +/// ## Examples +/// ```rust +/// use rust_persian_tools::national_id::{NationalIdError, verify_iranian_national_id}; +/// +/// assert!(verify_iranian_national_id("68415941").is_ok()); +/// assert!(verify_iranian_national_id("0200203241").is_ok()); +/// assert!(verify_iranian_national_id("0067749828").is_ok()); +/// +/// assert_eq!( +/// verify_iranian_national_id("0"), +/// Err(NationalIdError::Length(1)), +/// ); +/// assert_eq!( +/// verify_iranian_national_id("1230000000"), +/// Err(NationalIdError::Invalid) +/// ); +/// ``` +pub fn verify_iranian_national_id(code: impl AsRef) -> Result<(), NationalIdError> { + let code_str = code.as_ref(); - let code = ("00".to_owned() + &code)[code_length + 2 - 10..].to_string(); - if str::parse::(&code[3..9]).unwrap() == 0 { - return false; + let length = code_str.len(); + if !((8..=10).contains(&length)) { + return Err(NationalIdError::Length(length)); } - let last_number = - str::parse::(code.chars().last().unwrap().to_string().as_str()).unwrap(); + let code_u64 = code_str + .parse::() + .map_err(|source| NationalIdError::NumericConvert { source })?; - let mut sum = 0; - for i in 0..9 { - sum += str::parse::(&code[i..i + 1]).unwrap() * (10 - i); + if length == 10 && (code_u64 == 0 || are_digits_the_same(code_u64)) { + return Err(NationalIdError::Invalid); } + let code_str = &("00".to_owned() + code_str)[length + 2 - 10..]; + if code_str[3..9].parse::().unwrap() == 0 { + return Err(NationalIdError::Invalid); + } + + let mut sum = (0usize..9).fold(0, |sum, i| { + sum + code_str[i..i + 1].parse::().unwrap() * (10 - i) + }); sum %= 11; + let last_number = (code_u64 % 10) as usize; + if (sum < 2 && last_number == sum) || (sum >= 2 && last_number == 11 - sum) { + Ok(()) + } else { + Err(NationalIdError::Invalid) + } +} - (sum < 2 && last_number == sum) || (sum >= 2 && last_number == 11 - sum) +#[inline] +fn are_digits_the_same(mut number: u64) -> bool { + let last = number % 10; + while number != 0 { + let current = number % 10; + number /= 10; + if current != last { + return false; + } + } + true } #[cfg(test)] @@ -69,39 +148,80 @@ mod verify_iranian_national_id_tests { #[test] fn check_falsy() { - assert_eq!(verify_iranian_national_id(""), false); - assert_eq!(verify_iranian_national_id("12345"), false); - assert_eq!(verify_iranian_national_id("0"), false); - assert_eq!(verify_iranian_national_id("000000"), false); - assert_eq!(verify_iranian_national_id("12300000"), false); - assert_eq!(verify_iranian_national_id("123000000"), false); - assert_eq!(verify_iranian_national_id("1230000000"), false); - assert_eq!(verify_iranian_national_id("0000000000"), false); - assert_eq!(verify_iranian_national_id("4444444444"), false); - assert_eq!(verify_iranian_national_id("9999999999"), false); - assert_eq!(verify_iranian_national_id("0684159415"), false); - assert_eq!(verify_iranian_national_id("1111111111"), false); - assert_eq!(verify_iranian_national_id("079041a904"), false); // this is not in typescript version + assert_eq!( + verify_iranian_national_id(""), + Err(NationalIdError::Length(0)) + ); + assert_eq!( + verify_iranian_national_id("12345"), + Err(NationalIdError::Length(5)) + ); + assert_eq!( + verify_iranian_national_id("0"), + Err(NationalIdError::Length(1)) + ); + assert_eq!( + verify_iranian_national_id("000000"), + Err(NationalIdError::Length(6)) + ); + assert_eq!( + verify_iranian_national_id("12300000"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("123000000"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("1230000000"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("0000000000"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("4444444444"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("9999999999"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("0684159415"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("1111111111"), + Err(NationalIdError::Invalid) + ); + assert_eq!( + verify_iranian_national_id("079041a904"), + Err(NationalIdError::NumericConvert { + source: "079041a904".parse::().err().unwrap() + }) + ); // this is not in typescript version } #[test] fn check_truly() { - assert_eq!(verify_iranian_national_id("11537027"), true); - assert_eq!(verify_iranian_national_id("787833770"), true); - assert_eq!(verify_iranian_national_id("1583250689"), true); - assert_eq!(verify_iranian_national_id("0499370899"), true); - assert_eq!(verify_iranian_national_id("0790419904"), true); - assert_eq!(verify_iranian_national_id("0084575948"), true); - assert_eq!(verify_iranian_national_id("0963695398"), true); - assert_eq!(verify_iranian_national_id("0684159414"), true); - assert_eq!(verify_iranian_national_id("0067749828"), true); - assert_eq!(verify_iranian_national_id("0650451252"), true); - assert_eq!(verify_iranian_national_id("4032152314"), true); - assert_eq!(verify_iranian_national_id("0076229645"), true); - assert_eq!(verify_iranian_national_id("4271467685"), true); - assert_eq!(verify_iranian_national_id("0200203241"), true); - assert_eq!(verify_iranian_national_id("068415941"), true); - assert_eq!(verify_iranian_national_id("68415941"), true); - assert_eq!(verify_iranian_national_id("787833770"), true); + assert_eq!(verify_iranian_national_id("11537027"), Ok(())); + assert_eq!(verify_iranian_national_id("787833770"), Ok(())); + assert_eq!(verify_iranian_national_id("1583250689"), Ok(())); + assert_eq!(verify_iranian_national_id("0499370899"), Ok(())); + assert_eq!(verify_iranian_national_id("0790419904"), Ok(())); + assert_eq!(verify_iranian_national_id("0084575948"), Ok(())); + assert_eq!(verify_iranian_national_id("0963695398"), Ok(())); + assert_eq!(verify_iranian_national_id("0684159414"), Ok(())); + assert_eq!(verify_iranian_national_id("0067749828"), Ok(())); + assert_eq!(verify_iranian_national_id("0650451252"), Ok(())); + assert_eq!(verify_iranian_national_id("4032152314"), Ok(())); + assert_eq!(verify_iranian_national_id("0076229645"), Ok(())); + assert_eq!(verify_iranian_national_id("4271467685"), Ok(())); + assert_eq!(verify_iranian_national_id("0200203241"), Ok(())); + assert_eq!(verify_iranian_national_id("068415941"), Ok(())); + assert_eq!(verify_iranian_national_id("68415941"), Ok(())); + assert_eq!(verify_iranian_national_id("787833770"), Ok(())); } } diff --git a/src/national_id/serde.rs b/src/national_id/serde.rs new file mode 100644 index 0000000..2be7235 --- /dev/null +++ b/src/national_id/serde.rs @@ -0,0 +1,153 @@ +//! [serde] helpers to deserialize Iranian National Number. Enabled if `serde` Cargo feature is enabled. + +use crate::national_id::verify_iranian_national_id; +use serde::Deserializer; + +struct NationalId; +struct NationalIdOption; + +impl<'de> serde::de::Visitor<'de> for NationalId { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("expecting Iranian national-id, e.g. 0076229645") + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + verify_iranian_national_id(s) + .map(|_| s.to_string()) + .map_err(serde::de::Error::custom) + } +} + +impl<'de> serde::de::Visitor<'de> for NationalIdOption { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("expecting Iranian national-id, e.g. 0076229645") + } + + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_some(self, d: D) -> Result + where + D: Deserializer<'de>, + { + use serde::Deserialize; + let s: Option = Option::deserialize(d)?; + if let Some(s) = s { + verify_iranian_national_id(&s) + .map(|_| Some(s)) + .map_err(serde::de::Error::custom) + } else { + Ok(None) + } + } +} + +/// Deserializes Iranian National Number in [serde]. +/// +/// For more info see [crate::national_id] module example. +pub fn national_id_de<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(NationalId) +} + +/// Deserializes Iranian National Number (if exists) in [serde]. +/// +/// For more info see [crate::national_id] module example. +pub fn national_id_option_de<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserializer.deserialize_option(NationalIdOption) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::national_id::NationalIdError; + use serde::Deserialize; + + #[derive(Debug, PartialEq, Deserialize)] + struct FooStr { + id: String, + } + + #[derive(Debug, PartialEq, Deserialize)] + struct FooNationalId { + #[serde(deserialize_with = "national_id_de")] + id: String, + } + + #[derive(Debug, PartialEq, Deserialize)] + struct FooOptionStr { + #[serde(default)] + id: Option, + } + + #[derive(Debug, PartialEq, Deserialize)] + struct FooOptionNationalId { + #[serde(default, deserialize_with = "national_id_option_de")] + id: Option, + } + + #[test] + fn de() { + // Valid: + let json_str = "{\"id\": \"0076229645\"}"; + assert!(serde_json::from_str::(json_str).is_ok()); + assert!(serde_json::from_str::(json_str).is_ok()); + assert!(serde_json::from_str::(json_str).is_ok()); + assert_eq!( + serde_json::from_str::(json_str).unwrap(), + FooNationalId { + id: "0076229645".to_string() + } + ); + assert!(serde_json::from_str::(json_str).is_ok()); + assert_eq!( + serde_json::from_str::(json_str).unwrap(), + FooOptionNationalId { + id: Some("0076229645".to_string()) + } + ); + + // Invalid: + let json_str = "{\"id\": \"12345\"}"; + assert!(serde_json::from_str::(json_str).is_ok()); + assert!(serde_json::from_str::(json_str).is_err()); + assert!(serde_json::from_str::(json_str) + .err() + .unwrap() + .to_string() + .find(&NationalIdError::Length(5).to_string()) + .is_some()); + assert!(serde_json::from_str::(json_str).is_ok()); + assert!(serde_json::from_str::(json_str).is_err()); + assert!(serde_json::from_str::(json_str) + .err() + .unwrap() + .to_string() + .find(&NationalIdError::Length(5).to_string()) + .is_some()); + + // Test Option::None + let json_str = "{}"; + assert!(serde_json::from_str::(json_str).is_ok()); + assert!(serde_json::from_str::(json_str) + .unwrap() + .id + .is_none()); + } +}