luhncalc 1.1.0

Validate digit sequences with the Luhn algorithm and calculate the next check digit.
Documentation
//! Reusable helpers for validating digit sequences with the Luhn algorithm.
//!
//! # Examples
//!
//! ```rust
//! use luhncalc::{analyze, calculate_check_digit, validate};
//!
//! # fn main() -> Result<(), luhncalc::DigitSequenceError> {
//! assert!(!validate("1234567890")?);
//! assert_eq!(calculate_check_digit("1234567890")?, '3');
//!
//! let analysis = analyze("12345678903")?;
//! assert!(analysis.valid);
//! assert_eq!(analysis.next_check_digit, '1');
//! # Ok(())
//! # }
//! ```

use std::error::Error;
use std::fmt;

/// The combined result of validating a digit sequence and computing the next
/// check digit for that sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Analysis {
    /// Whether the provided sequence already passes the Luhn check.
    pub valid: bool,
    /// The digit that would make the provided sequence valid when appended.
    pub next_check_digit: char,
}

/// Error returned when the provided digit sequence cannot be processed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigitSequenceError {
    /// The provided sequence was empty.
    Empty,
    /// The provided sequence contained characters outside ASCII `0-9`.
    NonAsciiDigit,
}

impl fmt::Display for DigitSequenceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => write!(f, "digit sequence cannot be empty"),
            Self::NonAsciiDigit => write!(f, "digit sequence must contain only ASCII digits"),
        }
    }
}

impl Error for DigitSequenceError {}

/// Validate whether a digit sequence passes the Luhn algorithm.
pub fn validate(sequence: &str) -> Result<bool, DigitSequenceError> {
    validate_digit_sequence(sequence)?;
    Ok(luhn::valid(sequence))
}

/// Calculate the check digit that should be appended to a sequence.
pub fn calculate_check_digit(sequence: &str) -> Result<char, DigitSequenceError> {
    validate_digit_sequence(sequence)?;
    Ok(luhn::checksum(sequence.as_bytes()) as char)
}

/// Validate a sequence and calculate its next check digit in one call.
pub fn analyze(sequence: &str) -> Result<Analysis, DigitSequenceError> {
    validate_digit_sequence(sequence)?;

    Ok(Analysis {
        valid: luhn::valid(sequence),
        next_check_digit: luhn::checksum(sequence.as_bytes()) as char,
    })
}

fn validate_digit_sequence(sequence: &str) -> Result<(), DigitSequenceError> {
    if sequence.is_empty() {
        return Err(DigitSequenceError::Empty);
    }

    if !sequence.bytes().all(|byte| byte.is_ascii_digit()) {
        return Err(DigitSequenceError::NonAsciiDigit);
    }

    Ok(())
}

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

    #[test]
    fn validates_invalid_sequence() {
        assert_eq!(validate("1234567890"), Ok(false));
    }

    #[test]
    fn validates_valid_sequence() {
        assert_eq!(validate("12345678903"), Ok(true));
    }

    #[test]
    fn calculates_next_check_digit() {
        assert_eq!(calculate_check_digit("1234567890"), Ok('3'));
    }

    #[test]
    fn returns_full_analysis() {
        assert_eq!(
            analyze("12345678903"),
            Ok(Analysis {
                valid: true,
                next_check_digit: '1',
            })
        );
    }

    #[test]
    fn rejects_empty_sequences() {
        assert_eq!(analyze(""), Err(DigitSequenceError::Empty));
    }

    #[test]
    fn rejects_non_ascii_digits() {
        assert_eq!(analyze("12A3"), Err(DigitSequenceError::NonAsciiDigit));
        assert_eq!(analyze("123"), Err(DigitSequenceError::NonAsciiDigit));
    }
}