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 →