tckimlik 0.1.0

Validate Turkish national identification numbers (T.C. Kimlik No). A faithful port of the algorithm used by python-stdnum. Zero deps, no_std.
Documentation
//! # tckimlik — validate Turkish national identification numbers
//!
//! Validate a Turkish Identification Number (*T.C. Kimlik No.*) — the 11-digit number
//! assigned to every citizen of Turkey, whose last two digits are check digits. A faithful
//! Rust port of the algorithm used by
//! [`python-stdnum`](https://arthurdejong.org/python-stdnum/) and the JavaScript ecosystem.
//!
//! ```
//! use tckimlik::{is_valid, calc_check_digits};
//!
//! assert!(is_valid("17291716060"));
//! assert!(!is_valid("17291716050")); // wrong check digit
//! assert!(!is_valid("07291716092")); // must not start with 0
//!
//! // Compute the two check digits from the first nine:
//! assert_eq!(calc_check_digits("172917160").unwrap(), "60");
//! ```
//!
//! **Zero dependencies** and `#![no_std]`.

#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/tckimlik/0.1.0")]

extern crate alloc;

use alloc::string::{String, ToString};
use core::fmt;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// Why a number is not a valid T.C. Kimlik No.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
    /// The number does not have exactly 11 digits.
    InvalidLength,
    /// The number contains a non-digit, or starts with `0`.
    InvalidFormat,
    /// The check digits do not match.
    InvalidChecksum,
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Error::InvalidLength => "number must be 11 digits long",
            Error::InvalidFormat => "number must be all digits and not start with 0",
            Error::InvalidChecksum => "check digits do not match",
        };
        f.write_str(message)
    }
}

impl core::error::Error for Error {}

/// Strip surrounding whitespace (the minimal representation).
#[must_use]
pub fn compact(number: &str) -> String {
    number.trim().to_string()
}

/// Calculate the two check digits from the first nine characters of `number`.
///
/// Only the first nine characters are used (matching the reference's `number[:9]`); a shorter
/// input uses all of its characters.
///
/// # Errors
/// Returns [`Error::InvalidFormat`] if any of those characters is not a digit.
#[allow(clippy::cast_possible_wrap)] // `to_digit(10)` is always `0..=9`.
pub fn calc_check_digits(number: &str) -> Result<String, Error> {
    let mut weighted: i32 = 0;
    let mut plain: i32 = 0;
    for (i, character) in number.chars().take(9).enumerate() {
        let digit = character.to_digit(10).ok_or(Error::InvalidFormat)? as i32;
        // The weight is 3 at even positions and 1 at odd positions.
        weighted += if i % 2 == 0 { 3 } else { 1 } * digit;
        plain += digit;
    }

    let check1 = (10 - weighted).rem_euclid(10);
    let check2 = (check1 + plain).rem_euclid(10);
    Ok(alloc::format!("{check1}{check2}"))
}

/// Validate a T.C. Kimlik No., returning the compacted number on success.
///
/// # Errors
/// Returns [`Error::InvalidLength`], [`Error::InvalidFormat`], or [`Error::InvalidChecksum`].
pub fn validate(number: &str) -> Result<String, Error> {
    let number = compact(number);
    let bytes = number.as_bytes();

    if bytes.len() != 11 {
        return Err(Error::InvalidLength);
    }
    if !bytes.iter().all(u8::is_ascii_digit) || bytes[0] == b'0' {
        return Err(Error::InvalidFormat);
    }
    if number[9..11] != calc_check_digits(&number[..9])? {
        return Err(Error::InvalidChecksum);
    }
    Ok(number)
}

/// Whether `number` is a valid T.C. Kimlik No.
///
/// ```
/// # use tckimlik::is_valid;
/// assert!(is_valid("10000000146"));
/// assert!(!is_valid("1234567890"));
/// ```
#[must_use]
pub fn is_valid(number: &str) -> bool {
    validate(number).is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_numbers() {
        assert!(is_valid("17291716060"));
        assert!(is_valid("10000000146"));
        assert!(is_valid("12345678950"));
        assert_eq!(validate(" 17291716060 ").unwrap(), "17291716060"); // trims whitespace
    }

    #[test]
    fn invalid_numbers() {
        assert_eq!(validate("17291716050"), Err(Error::InvalidChecksum));
        assert_eq!(validate("1729171606"), Err(Error::InvalidLength)); // 10 digits
        assert_eq!(validate("07291716092"), Err(Error::InvalidFormat)); // leading zero
        assert_eq!(validate("1729171606a"), Err(Error::InvalidFormat)); // non-digit
        assert!(!is_valid(""));
    }

    #[test]
    fn check_digits() {
        assert_eq!(calc_check_digits("172917160").unwrap(), "60");
        assert_eq!(calc_check_digits("100000001").unwrap(), "46");
        // Only the first 9 characters are used; a non-digit among them errors.
        assert_eq!(
            calc_check_digits("1234567x9").unwrap_err(),
            Error::InvalidFormat
        );
    }

    #[test]
    fn generate_then_validate() {
        // For any valid first-nine, appending the check digits gives a valid number.
        for first_nine in ["123456789", "987654321", "100000001", "555555555"] {
            let check = calc_check_digits(first_nine).unwrap();
            let full = alloc::format!("{first_nine}{check}");
            assert!(is_valid(&full), "{full} should be valid");
        }
    }
}