Skip to content

Credit Card Validator — Rust source

Validate credit card numbers with the Luhn checksum and detect the issuing brand (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay, Maestro). 100% client-side - nothing is transmitted.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

// credit-card-validator — Rust.
// CosmoDev polyglot showcase port of the credit-card-validator tool,
// ported from src/lib/creditCard.ts.
//
// Display source — part of CosmoDev's polyglot tool pages.
//
// Pure credit-card validation with no I/O: sanitize to digits, run the Luhn
// checksum, detect the issuing brand by prefix + length, and group the number
// for display. Never panics on malformed input; mirrors the TypeScript contract.
//
// Stdlib-only (no external crates). Because Rust's `regex` crate is not stdlib,
// the brand prefixes are matched by hand on the byte slice instead of regexes.
// All sanitized digits are ASCII, so byte indexing is correct and UTF-8-safe.

/// Full validation result. `brand` is `None` when the issuer is unknown
/// (the Rust equivalent of TypeScript's `string | null`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CardInfo {
    pub input: String,
    pub sanitized: String,
    pub valid: bool,
    pub luhn_valid: bool,
    pub brand: Option<&'static str>,
    pub formatted: String,
}

/// Luhn (mod-10) checksum. Empty or non-digit input returns `false`.
pub fn luhn_check(digits: &str) -> bool {
    if digits.is_empty() {
        return false;
    }
    let mut sum: u32 = 0;
    let mut should_double = false;
    // Walk right-to-left; double every second digit, collapsing 10–18 to its digit-sum.
    for &byte in digits.as_bytes().iter().rev() {
        let mut d = match byte {
            b'0'..=b'9' => (byte - b'0') as u32,
            _ => return false, // non-digit → invalid, matching /^\d*$/
        };
        if should_double {
            d *= 2;
            if d > 9 {
                d -= 9;
            }
        }
        sum += d;
        should_double = !should_double;
    }
    sum % 10 == 0
}

/// Detect the card brand by prefix + length. Returns `None` when unknown.
///
/// Order matters: the first prefix+length match wins. Maestro is last because
/// its broad "6" / 5x ranges overlap narrower issuers (Discover, UnionPay, …).
pub fn detect_brand(digits: &str) -> Option<&'static str> {
    if digits.is_empty() {
        return None;
    }
    let b = digits.as_bytes();
    let n = b.len();
    let len_ok = |lengths: &[usize]| lengths.contains(&n);

    // Visa: leading 4.
    if b.starts_with(b"4") && len_ok(&[13, 16, 19]) {
        return Some("Visa");
    }
    // Mastercard: 51–55, or 2221–2720.
    if matches_mastercard(b) && len_ok(&[16]) {
        return Some("Mastercard");
    }
    // American Express: 34 or 37.
    if (b.starts_with(b"34") || b.starts_with(b"37")) && len_ok(&[15]) {
        return Some("American Express");
    }
    // Discover: 6011, 65, 644–649, or 622126–622925.
    if matches_discover(b) && len_ok(&[16, 19]) {
        return Some("Discover");
    }
    // Diners Club (incl. Carte Blanche / international): 300–305, 3095, 36, 38, 39, 54, 55.
    if matches_diners(b) && len_ok(&[14, 16]) {
        return Some("Diners Club");
    }
    // JCB: 3528–3589.
    if matches_jcb(b) && len_ok(&[16, 17, 18, 19]) {
        return Some("JCB");
    }
    // UnionPay: leading 62.
    if b.starts_with(b"62") && len_ok(&[16, 17, 18, 19]) {
        return Some("UnionPay");
    }
    // Maestro: 50, 56–58, or any leading 6 (catch-all for 6xxx issuers).
    if matches_maestro(b) && len_ok(&[12, 13, 14, 15, 16, 17, 18, 19]) {
        return Some("Maestro");
    }
    None
}

// ---- Brand prefix matchers (hand-rolled equivalents of the TS regexes). ----
// Each operates on the raw byte slice; comparisons use ASCII byte literals.

fn matches_mastercard(b: &[u8]) -> bool {
    // 5[1-5]
    if b.len() >= 2 && b[0] == b'5' && (b'1'..=b'5').contains(&b[1]) {
        return true;
    }
    // 2(2[2-9] | [3-6][0-9] | 7[01] | 720)
    if b.len() >= 2 && b[0] == b'2' {
        match b[1] {
            // 22[2-9]
            b'2' if b.len() >= 3 && (b'2'..=b'9').contains(&b[2]) => return true,
            // 2[3-6][0-9]
            b'3'..=b'6' if b.len() >= 3 && b[2].is_ascii_digit() => return true,
            // 27[01], and the explicit 720 range
            b'7' => {
                if b.len() >= 3 && (b[2] == b'0' || b[2] == b'1') {
                    return true;
                }
                if b.starts_with(b"2720") {
                    return true;
                }
            }
            _ => {}
        }
    }
    false
}

fn matches_discover(b: &[u8]) -> bool {
    // 6011
    if b.starts_with(b"6011") {
        return true;
    }
    if b.len() < 2 || b[0] != b'6' {
        return false;
    }
    // 65
    if b[1] == b'5' {
        return true;
    }
    // 64[4-9]
    if b[1] == b'4' && b.len() >= 3 && (b'4'..=b'9').contains(&b[2]) {
        return true;
    }
    // 622( 12[6-9] | 1[3-9][0-9] | [2-8][0-9][0-9] | 9([01][0-9] | 2[0-5]) )
    // The inner group consumes indices 3,4,5.
    if b.starts_with(b"622") && b.len() >= 6 {
        let (c3, c4, c5) = (b[3], b[4], b[5]);
        if c3 == b'1' && c4 == b'2' && (b'6'..=b'9').contains(&c5) {
            return true;
        }
        if c3 == b'1' && (b'3'..=b'9').contains(&c4) && c5.is_ascii_digit() {
            return true;
        }
        if (b'2'..=b'8').contains(&c3) && c4.is_ascii_digit() && c5.is_ascii_digit() {
            return true;
        }
        if c3 == b'9' {
            if (c4 == b'0' || c4 == b'1') && c5.is_ascii_digit() {
                return true;
            }
            if c4 == b'2' && (b'0'..=b'5').contains(&c5) {
                return true;
            }
        }
    }
    false
}

fn matches_diners(b: &[u8]) -> bool {
    // 36, 38, 39, 54, 55
    if [b"36", b"38", b"39", b"54", b"55"].iter().any(|p| b.starts_with(p)) {
        return true;
    }
    // 30[0-5]
    if b.len() >= 3 && b[0] == b'3' && b[1] == b'0' && (b'0'..=b'5').contains(&b[2]) {
        return true;
    }
    // 3095 (separate alternative: the 0-5 range above does not cover the '9')
    if b.starts_with(b"3095") {
        return true;
    }
    false
}

fn matches_jcb(b: &[u8]) -> bool {
    // 35( 2[89] | [3-8][0-9] ) — the inner group needs indices 2 and 3.
    if b.len() < 4 || !(b[0] == b'3' && b[1] == b'5') {
        return false;
    }
    let (c2, c3) = (b[2], b[3]);
    let two_eight_nine = c2 == b'2' && (c3 == b'8' || c3 == b'9');
    let three_to_eight_digit = (b'3'..=b'8').contains(&c2) && c3.is_ascii_digit();
    two_eight_nine || three_to_eight_digit
}

fn matches_maestro(b: &[u8]) -> bool {
    if b.is_empty() {
        return false;
    }
    // 50, 56, 57, 58
    if b.len() >= 2 && b[0] == b'5' && (b[1] == b'0' || (b'6'..=b'8').contains(&b[1])) {
        return true;
    }
    // any leading 6
    b[0] == b'6'
}

/// Slice digits into runs of the given widths, joining with spaces.
fn group(digits: &str, widths: &[usize]) -> String {
    let b = digits.as_bytes();
    let mut parts: Vec<&str> = Vec::with_capacity(widths.len() + 1);
    let mut i = 0;
    for &w in widths {
        if i >= b.len() {
            break;
        }
        let end = (i + w).min(b.len());
        // Safe: digits are ASCII, so every byte boundary is a UTF-8 char boundary.
        parts.push(std::str::from_utf8(&b[i..end]).unwrap());
        i += w;
    }
    // Any trailing remainder (e.g. a partially typed number) is appended as-is.
    if i < b.len() {
        parts.push(std::str::from_utf8(&b[i..]).unwrap());
    }
    parts.join(" ")
}

/// Insert a space every 4 digits, omitting any trailing space
/// (equivalent to the TS regex `(.{4})(?=.)` replacement).
fn default_group(digits: &str) -> String {
    let b = digits.as_bytes();
    let mut out = String::with_capacity(digits.len() + digits.len() / 4);
    for (i, &byte) in b.iter().enumerate() {
        if i > 0 && i % 4 == 0 {
            out.push(' ');
        }
        out.push(byte as char); // ASCII digit → its char
    }
    out
}

/// Group the number per brand spacing rules.
pub fn format_card(digits: &str, brand: Option<&str>) -> String {
    if digits.is_empty() {
        return String::new();
    }
    // Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
    match brand {
        Some("American Express") => return group(digits, &[4, 6, 5]),
        Some("Diners Club") if digits.len() == 14 => return group(digits, &[4, 6, 4]),
        _ => {}
    }
    default_group(digits)
}

/// Keep ASCII digit bytes only; multibyte UTF-8 bytes are all >= 0x80, so excluded.
fn sanitize_digits(input: &str) -> String {
    input
        .bytes()
        .filter(|b| b.is_ascii_digit())
        .map(|b| b as char)
        .collect()
}

/// Validate a card number. Always returns a `CardInfo`; never panics.
pub fn validate_card(input: &str) -> CardInfo {
    let sanitized = sanitize_digits(input);
    let brand = detect_brand(&sanitized);
    // Luhn is only meaningful at plausible card lengths (>= 12 digits).
    let luhn_valid = sanitized.len() >= 12 && luhn_check(&sanitized);
    let formatted = format_card(&sanitized, brand);
    CardInfo {
        input: input.to_string(),
        sanitized,
        valid: luhn_valid,
        luhn_valid,
        brand,
        formatted,
    }
}

fn main() {
    // Tiny showcase: validate a well-known test number (Visa).
    let info = validate_card("4111 1111 1111 1111");
    println!(
        "brand={:?} valid={} formatted={:?}",
        info.brand, info.valid, info.formatted
    );
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →