German ID card number parsing

As a parsing and validation erxercise, I implemented the parsing and validation of German ID card numbers.

Link to German wiki page (may need translation with the tool of your choosing): Ausweisnummer – Wikipedia

main.rs

use std::env::args;

use error::IdCardNumberParseError;
use id_card_number::IdCardNumber;

mod error;
mod id_card_number;

fn main() {
    let number = args().nth(1).expect("Please provide a number");

    match number.parse::<IdCardNumber>() {
        Ok(id_card) => println!("OK: {id_card}"),
        Err(err) => eprintln!("ERROR: {err}"),
    }
}

error.rs

use std::error::Error;
use std::fmt::{Display, Formatter};

/// Errors that may occur when parsing an ID card number.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdCardNumberParseError {
    /// The ID card number has an invalid length.
    InvalidLength,
    /// The ID card number contains an invalid character.
    InvalidChar(char),
    /// The ID card number's checksum does not match.
    ChecksumMismatch { calculated: u32, expected: u32 },
}

impl Display for IdCardNumberParseError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidLength => write!(f, "The length of the string is invalid."),
            Self::InvalidChar(chr) => {
                write!(f, "Encountered invalid character in number: {chr}")
            }
            Self::ChecksumMismatch {
                calculated,
                expected,
            } => write!(
                f,
                "Checksum mismatch. Expected {expected} but calculated {calculated}."
            ),
        }
    }
}

impl Error for IdCardNumberParseError {}

id_card_number.rs

use std::array::from_fn;
use std::fmt::Display;
use std::str::FromStr;

use crate::IdCardNumberParseError;

const LENGTH: usize = 9;
const LENGTH_WITH_CHECK_DIGIT: usize = LENGTH + 1;
const RADIX: u32 = 36;
const WEIGHTS: [u32; LENGTH] = [7, 3, 1, 7, 3, 1, 7, 3, 1];

/// Representation of a German ID card number.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct IdCardNumber {
    chars: [char; LENGTH],
}

impl IdCardNumber {
    /// Calculate the ID card number's checksum.
    pub fn checksum(self) -> u32 {
        self.chars
            .into_iter()
            .zip(WEIGHTS)
            .map(|(chr, weight)| {
                chr.to_digit(RADIX)
                    .expect("Encountered invalid char in ID card number. This should never happen.")
                    * weight
            })
            .sum()
    }

    /// Return the checksum digit.
    pub fn check_digit(&self) -> u32 {
        self.checksum() % 10
    }
}

impl Display for IdCardNumber {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        self.chars.iter().collect::<String>().fmt(f)
    }
}

impl TryFrom<[char; LENGTH]> for IdCardNumber {
    type Error = IdCardNumberParseError;

    fn try_from(chars: [char; LENGTH]) -> Result<Self, Self::Error> {
        for char in chars {
            if char.to_digit(RADIX).is_none() {
                return Err(IdCardNumberParseError::InvalidChar(char));
            }
        }

        Ok(IdCardNumber { chars })
    }
}

impl TryFrom<[char; LENGTH_WITH_CHECK_DIGIT]> for IdCardNumber {
    type Error = IdCardNumberParseError;

    fn try_from(chars: [char; LENGTH_WITH_CHECK_DIGIT]) -> Result<Self, Self::Error> {
        let [chars @ .., check_char] = chars;
        let id_card = Self::try_from(chars)?;
        let calculated = id_card.check_digit();
        let expected = check_char
            .to_digit(10)
            .ok_or(IdCardNumberParseError::InvalidChar(check_char))?;

        if calculated == expected {
            Ok(id_card)
        } else {
            Err(IdCardNumberParseError::ChecksumMismatch {
                calculated,
                expected,
            })
        }
    }
}

impl FromStr for IdCardNumber {
    type Err = IdCardNumberParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut chars = s.chars();
        let array: [char; LENGTH] = [
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
            chars.next().ok_or(IdCardNumberParseError::InvalidLength)?,
        ];

        let Some(checksum) = chars.next() else {
            return Self::try_from(array);
        };

        if let Some(_excess_byte) = chars.next() {
            return Err(IdCardNumberParseError::InvalidLength);
        }

        Self::try_from(from_fn::<char, LENGTH_WITH_CHECK_DIGIT, _>(|index| {
            // LENGTH_WITH_CHECK_DIGIT = LENGTH + 1
            // Hence, the OR case only occurs on the last missing (checksum) digit.
            array.get(index).copied().unwrap_or(checksum)
        }))
    }
}

I'd appreciate any feedback on how the code may be improved.
NB: I intentionally did not use clap to keep main.rs simple.
PS: Example of a valid ID card number with checksum: T220001293

Having a few unit tests in the code itself would make things a lot easier, in this regard. Even just one: to test against an arbitrary, yet valid number. Working it out from ground up is a bit of PITA.

Given how much you're using Result<...> in the rest of the code, this seems a bit out of place.

Summary
// personal preference
let Some(num) = args().nth(1) else {
    eprintln!("ERROR: no number provided.");
    return;
}

The comments are somewhat redundant here: the enum itself is self-explanatory enough.

Feels a lot more complicated that it needs to be, though refactoring isn't exactly trivial.

instead of
you can
//split the input into individual `char`s
let chars: Vec<_> = s.chars().collect();
// break down the input into (9-digit) `id` + `checksum` parts
let id_check: Vec<_> = chars.chunks(ID_LEN).collect();
// if no match, the whole number must be invalid
let [ id_chars, check ] = id_check.as_slice() else {
    return Err(IdErr::InvalidLength);
};

Splitting the (core) logic across a bunch of TryFrom's only adds some fuel to the fire. So does parsing the ID first, and validating it against its own checksum afterwards. If the validation is part of the parsing mechanism itself, don't split it out to begin with. Reminds me of the difference in between the inherent and accidental complexity (John Ousterhout, was it?) ...

from his 'Philosophy of Software Design'

Complexity in software design is often categorized into two primary types: accidental complexity and intrinsic complexity. Accidental complexity arises not from the inherent nature of the problem but from the shortcomings in the practical implementation of software solutions. This includes poor design decisions, inadequate abstractions, and improper usage of programming languages and tools. Intrinsic complexity, on the other hand, is inherent to the problem being solved and cannot be eliminated.

Accidental complexity is, therefore, a significant concern in software engineering because it is often introduced by the developers themselves. These complexities usually manifest in the form of convoluted code, unmanageable modules, and incomprehensible logic. Intrinsically complex code is unavoidable since it stems from the essential nature of the problem domain, but dealing effectively with accidental complexity is critical for making software systems more maintainable and less error-prone.

Complete example here.

1 Like