Skip to main content

credit_card_type/
lib.rs

1//! # credit-card-type — detect a credit card brand from a (partial) number
2//!
3//! Determine the card brand (Visa, Mastercard, American Express, …) from a full **or
4//! partial** card number — designed for *type-as-you-go* detection as a user types. It is
5//! **not** a validation library; it is the building block a payment UI uses to show the
6//! right brand icon, apply the right spacing, and hint the CVV length.
7//!
8//! A faithful Rust port of Braintree's
9//! [`credit-card-type`](https://www.npmjs.com/package/credit-card-type) npm package, which
10//! has no Rust equivalent. **Zero dependencies** and `#![no_std]`.
11//!
12//! ```
13//! use credit_card_type::credit_card_type;
14//!
15//! // A complete-enough prefix resolves to a single brand.
16//! let cards = credit_card_type("4111");
17//! assert_eq!(cards.len(), 1);
18//! assert_eq!(cards[0].type_, "visa");
19//! assert_eq!(cards[0].nice_type, "Visa");
20//! assert_eq!(cards[0].gaps, &[4, 8, 12]);
21//! assert_eq!(cards[0].code.name, "CVV");
22//!
23//! // An ambiguous prefix returns every candidate brand.
24//! let ambiguous = credit_card_type("6");
25//! assert!(ambiguous.len() > 1);
26//! ```
27//!
28//! The input should be **normalized** (digits only, no spaces or separators) before use.
29
30#![no_std]
31#![forbid(unsafe_code)]
32#![doc(html_root_url = "https://docs.rs/credit-card-type/0.1.0")]
33
34extern crate alloc;
35
36use alloc::vec::Vec;
37
38// Compile-test the README's examples as part of `cargo test`.
39#[cfg(doctest)]
40#[doc = include_str!("../README.md")]
41struct ReadmeDoctests;
42
43/// The security-code metadata for a card brand (e.g. `CVV`, size 3).
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Code {
46    /// The brand's name for its security code, e.g. `CVV`, `CVC`, `CID`.
47    pub name: &'static str,
48    /// The expected number of digits in the security code.
49    pub size: u8,
50}
51
52/// A detected (candidate) card brand and its formatting metadata.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct CardType {
55    /// A code-friendly brand identifier, e.g. `visa`, `american-express`.
56    pub type_: &'static str,
57    /// A human-friendly brand name, e.g. `Visa`, `American Express`.
58    pub nice_type: &'static str,
59    /// The indices at which to insert spaces when formatting the number
60    /// (e.g. `[4, 8, 12]` ⇒ `4111 1111 1111 1111`).
61    pub gaps: &'static [u8],
62    /// The valid total lengths of the card number (digits only).
63    pub lengths: &'static [u8],
64    /// The security-code metadata.
65    pub code: Code,
66    /// How specifically the input matched this brand's pattern: `Some(n)` when the input is
67    /// at least `n` digits long and matched an `n`-digit pattern, else `None`.
68    pub match_strength: Option<usize>,
69}
70
71enum Pattern {
72    Prefix(&'static str),
73    Range(&'static str, &'static str),
74}
75
76struct Def {
77    type_: &'static str,
78    nice_type: &'static str,
79    patterns: &'static [Pattern],
80    gaps: &'static [u8],
81    lengths: &'static [u8],
82    code: Code,
83}
84
85impl Def {
86    fn to_card(&self, match_strength: Option<usize>) -> CardType {
87        CardType {
88            type_: self.type_,
89            nice_type: self.nice_type,
90            gaps: self.gaps,
91            lengths: self.lengths,
92            code: self.code,
93            match_strength,
94        }
95    }
96}
97
98/// Code-friendly brand identifiers returned in [`CardType::type_`].
99pub mod brand {
100    /// `visa`
101    pub const VISA: &str = "visa";
102    /// `mastercard`
103    pub const MASTERCARD: &str = "mastercard";
104    /// `american-express`
105    pub const AMERICAN_EXPRESS: &str = "american-express";
106    /// `diners-club`
107    pub const DINERS_CLUB: &str = "diners-club";
108    /// `discover`
109    pub const DISCOVER: &str = "discover";
110    /// `jcb`
111    pub const JCB: &str = "jcb";
112    /// `unionpay`
113    pub const UNIONPAY: &str = "unionpay";
114    /// `naranja`
115    pub const NARANJA: &str = "naranja";
116    /// `verve`
117    pub const VERVE: &str = "verve";
118    /// `maestro`
119    pub const MAESTRO: &str = "maestro";
120    /// `elo`
121    pub const ELO: &str = "elo";
122    /// `mir`
123    pub const MIR: &str = "mir";
124    /// `hiper`
125    pub const HIPER: &str = "hiper";
126    /// `hipercard`
127    pub const HIPERCARD: &str = "hipercard";
128}
129
130use Pattern::{Prefix as P, Range as R};
131
132/// Card definitions in the original test order.
133static DEFS: &[Def] = &[
134    Def {
135        type_: "visa",
136        nice_type: "Visa",
137        patterns: &[P("4")],
138        gaps: &[4, 8, 12],
139        lengths: &[16, 18, 19],
140        code: Code {
141            name: "CVV",
142            size: 3,
143        },
144    },
145    Def {
146        type_: "mastercard",
147        nice_type: "Mastercard",
148        patterns: &[
149            R("51", "55"),
150            R("2221", "2229"),
151            R("223", "229"),
152            R("23", "26"),
153            R("270", "271"),
154            P("2720"),
155        ],
156        gaps: &[4, 8, 12],
157        lengths: &[16],
158        code: Code {
159            name: "CVC",
160            size: 3,
161        },
162    },
163    Def {
164        type_: "american-express",
165        nice_type: "American Express",
166        patterns: &[P("34"), P("37")],
167        gaps: &[4, 10],
168        lengths: &[15],
169        code: Code {
170            name: "CID",
171            size: 4,
172        },
173    },
174    Def {
175        type_: "diners-club",
176        nice_type: "Diners Club",
177        patterns: &[R("300", "305"), P("36"), P("38"), P("39")],
178        gaps: &[4, 10],
179        lengths: &[14, 16, 19],
180        code: Code {
181            name: "CVV",
182            size: 3,
183        },
184    },
185    Def {
186        type_: "discover",
187        nice_type: "Discover",
188        patterns: &[P("6011"), R("644", "649"), P("65")],
189        gaps: &[4, 8, 12],
190        lengths: &[16, 19],
191        code: Code {
192            name: "CID",
193            size: 3,
194        },
195    },
196    Def {
197        type_: "jcb",
198        nice_type: "JCB",
199        patterns: &[P("2131"), P("1800"), R("3528", "3589")],
200        gaps: &[4, 8, 12],
201        lengths: &[16, 17, 18, 19],
202        code: Code {
203            name: "CVV",
204            size: 3,
205        },
206    },
207    Def {
208        type_: "unionpay",
209        nice_type: "UnionPay",
210        patterns: &[
211            P("620"),
212            R("62100", "62182"),
213            R("62184", "62187"),
214            R("62185", "62197"),
215            R("62200", "62205"),
216            R("622010", "622999"),
217            P("622018"),
218            R("62207", "62209"),
219            R("623", "626"),
220            P("6270"),
221            P("6272"),
222            P("6276"),
223            R("627700", "627779"),
224            R("627781", "627799"),
225            R("6282", "6289"),
226            P("6291"),
227            P("6292"),
228            P("810"),
229            R("8110", "8131"),
230            R("8132", "8151"),
231            R("8152", "8163"),
232            R("8164", "8171"),
233        ],
234        gaps: &[4, 8, 12],
235        lengths: &[14, 15, 16, 17, 18, 19],
236        code: Code {
237            name: "CVN",
238            size: 3,
239        },
240    },
241    Def {
242        type_: "naranja",
243        nice_type: "Naranja",
244        patterns: &[P("589562"), P("402918"), P("527572")],
245        gaps: &[4, 8, 12],
246        lengths: &[16],
247        code: Code {
248            name: "CVV",
249            size: 3,
250        },
251    },
252    Def {
253        type_: "verve",
254        nice_type: "Verve",
255        patterns: &[
256            R("506099", "506127"),
257            P("506129"),
258            R("506133", "506150"),
259            R("506158", "506163"),
260            P("506166"),
261            P("506168"),
262            P("506170"),
263            P("506173"),
264            R("506176", "506180"),
265            P("506184"),
266            R("506187", "506188"),
267            P("506191"),
268            P("506195"),
269            P("506197"),
270            P("507865"),
271            P("507866"),
272            R("507868", "507877"),
273            R("507880", "507888"),
274            P("507900"),
275            P("507941"),
276        ],
277        gaps: &[4, 8, 12],
278        lengths: &[16, 18, 19],
279        code: Code {
280            name: "CVV",
281            size: 3,
282        },
283    },
284    Def {
285        type_: "maestro",
286        nice_type: "Maestro",
287        patterns: &[
288            P("493698"),
289            R("500000", "504174"),
290            R("504176", "506698"),
291            R("506779", "508999"),
292            R("56", "59"),
293            P("63"),
294            P("67"),
295            P("6"),
296        ],
297        gaps: &[4, 8, 12],
298        lengths: &[12, 13, 14, 15, 16, 17, 18, 19],
299        code: Code {
300            name: "CVC",
301            size: 3,
302        },
303    },
304    Def {
305        type_: "elo",
306        nice_type: "Elo",
307        patterns: &[
308            P("401178"),
309            P("401179"),
310            P("438935"),
311            P("457631"),
312            P("457632"),
313            P("431274"),
314            P("451416"),
315            P("457393"),
316            P("504175"),
317            R("506699", "506778"),
318            R("509000", "509999"),
319            P("627780"),
320            P("636297"),
321            P("636368"),
322            R("650031", "650033"),
323            R("650035", "650051"),
324            R("650405", "650439"),
325            R("650485", "650538"),
326            R("650541", "650598"),
327            R("650700", "650718"),
328            R("650720", "650727"),
329            R("650901", "650978"),
330            R("651652", "651679"),
331            R("655000", "655019"),
332            R("655021", "655058"),
333        ],
334        gaps: &[4, 8, 12],
335        lengths: &[16],
336        code: Code {
337            name: "CVE",
338            size: 3,
339        },
340    },
341    Def {
342        type_: "mir",
343        nice_type: "Mir",
344        patterns: &[R("2200", "2204")],
345        gaps: &[4, 8, 12],
346        lengths: &[16, 17, 18, 19],
347        code: Code {
348            name: "CVP2",
349            size: 3,
350        },
351    },
352    Def {
353        type_: "hiper",
354        nice_type: "Hiper",
355        patterns: &[
356            P("637095"),
357            P("63737423"),
358            P("63743358"),
359            P("637568"),
360            P("637599"),
361            P("637609"),
362            P("637612"),
363        ],
364        gaps: &[4, 8, 12],
365        lengths: &[16],
366        code: Code {
367            name: "CVC",
368            size: 3,
369        },
370    },
371    Def {
372        type_: "hipercard",
373        nice_type: "Hipercard",
374        patterns: &[P("606282")],
375        gaps: &[4, 8, 12],
376        lengths: &[16],
377        code: Code {
378            name: "CVC",
379            size: 3,
380        },
381    },
382];
383
384/// Detect the card brand(s) for a (possibly partial) card number.
385///
386/// Returns a list of candidate [`CardType`]s. When the input is long enough to identify a
387/// single brand conclusively, the list has exactly one entry; an ambiguous (or empty) input
388/// returns every still-possible brand, in a stable order. An input with no possible brand
389/// returns an empty list.
390///
391/// ```
392/// # use credit_card_type::credit_card_type;
393/// assert_eq!(credit_card_type("378282").first().unwrap().type_, "american-express");
394/// assert!(credit_card_type("9999").is_empty());
395/// assert_eq!(credit_card_type("").len(), 14); // all brands
396/// ```
397#[must_use]
398pub fn credit_card_type(card_number: &str) -> Vec<CardType> {
399    if card_number.is_empty() {
400        return DEFS.iter().map(|d| d.to_card(None)).collect();
401    }
402
403    let mut results: Vec<CardType> = Vec::new();
404    for def in DEFS {
405        add_matching(card_number, def, &mut results);
406    }
407
408    if let Some(best) = find_best_match(&results) {
409        return alloc::vec![best];
410    }
411    results
412}
413
414/// Look up a single brand's metadata by its [`brand`] identifier (e.g. `"visa"`), or `None`
415/// if the identifier is not recognized.
416#[must_use]
417pub fn get_type_info(card_type: &str) -> Option<CardType> {
418    DEFS.iter()
419        .find(|d| d.type_ == card_type)
420        .map(|d| d.to_card(None))
421}
422
423fn add_matching(card_number: &str, def: &Def, results: &mut Vec<CardType>) {
424    for pattern in def.patterns {
425        if !matches(card_number, pattern) {
426            continue;
427        }
428        let pattern_length = match pattern {
429            Pattern::Prefix(p) => p.len(),
430            Pattern::Range(min, _) => min.len(),
431        };
432        let match_strength = if card_number.len() >= pattern_length {
433            Some(pattern_length)
434        } else {
435            None
436        };
437        results.push(def.to_card(match_strength));
438        break;
439    }
440}
441
442fn matches(card_number: &str, pattern: &Pattern) -> bool {
443    match pattern {
444        Pattern::Prefix(p) => matches_prefix(card_number, p),
445        Pattern::Range(min, max) => matches_range(card_number, min, max),
446    }
447}
448
449/// A prefix matches if the shorter of (card, pattern) is a prefix of the longer.
450fn matches_prefix(card_number: &str, pattern: &str) -> bool {
451    let n = card_number.len().min(pattern.len());
452    card_number.as_bytes()[..n] == pattern.as_bytes()[..n]
453}
454
455fn matches_range(card_number: &str, min: &str, max: &str) -> bool {
456    let sub_len = card_number.len().min(min.len());
457    let card_prefix = &card_number.as_bytes()[..sub_len];
458    let (Some(value), Some(lo), Some(hi)) = (
459        parse_int_prefix(card_prefix),
460        parse_int_prefix(&min.as_bytes()[..sub_len.min(min.len())]),
461        parse_int_prefix(&max.as_bytes()[..sub_len.min(max.len())]),
462    ) else {
463        return false;
464    };
465    value >= lo && value <= hi
466}
467
468/// `parseInt(_, 10)` over leading ASCII digits; `None` if there are none.
469fn parse_int_prefix(bytes: &[u8]) -> Option<u64> {
470    let digits = &bytes[..bytes.iter().take_while(|b| b.is_ascii_digit()).count()];
471    if digits.is_empty() {
472        return None;
473    }
474    let mut value: u64 = 0;
475    for &b in digits {
476        value = value.wrapping_mul(10).wrapping_add(u64::from(b - b'0'));
477    }
478    Some(value)
479}
480
481/// If every candidate is determinate (has a `match_strength`), pick the most specific.
482fn find_best_match(results: &[CardType]) -> Option<CardType> {
483    let with_strength = results
484        .iter()
485        .filter(|r| r.match_strength.is_some())
486        .count();
487    if with_strength == 0 || with_strength != results.len() {
488        return None;
489    }
490    results.iter().copied().reduce(|best, result| {
491        if best.match_strength.unwrap_or(0) < result.match_strength.unwrap_or(0) {
492            result
493        } else {
494            best
495        }
496    })
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    fn types(number: &str) -> Vec<&'static str> {
504        credit_card_type(number).iter().map(|c| c.type_).collect()
505    }
506
507    #[test]
508    fn detects_major_brands() {
509        assert_eq!(types("4111111111111111"), ["visa"]);
510        assert_eq!(types("5500005555555559"), ["mastercard"]);
511        assert_eq!(types("378282246310005"), ["american-express"]);
512        assert_eq!(types("6011111111111117"), ["discover"]);
513        assert_eq!(types("3530111333300000"), ["jcb"]);
514        assert_eq!(types("30569309025904"), ["diners-club"]);
515    }
516
517    #[test]
518    fn mastercard_2_series() {
519        assert_eq!(types("2221000000000009"), ["mastercard"]);
520        assert_eq!(types("2720990000000013"), ["mastercard"]);
521    }
522
523    #[test]
524    fn partial_visa_is_determinate() {
525        // "4" alone is ambiguous (Brazilian elo/naranja/maestro also begin 4xxxxx),
526        // but "41" rules them out, leaving only Visa.
527        let cards = credit_card_type("41");
528        assert_eq!(cards.len(), 1);
529        assert_eq!(cards[0].type_, "visa");
530        assert_eq!(cards[0].match_strength, Some(1));
531    }
532
533    #[test]
534    fn ambiguous_prefix_returns_all_candidates() {
535        let cards = credit_card_type("6");
536        let names: Vec<_> = cards.iter().map(|c| c.type_).collect();
537        // discover, unionpay, maestro, elo, hiper, hipercard all start with 6
538        assert!(names.contains(&"discover"));
539        assert!(names.contains(&"maestro"));
540        assert!(names.len() > 1);
541        // none are determinate yet, so no best match collapse
542        assert!(cards.iter().all(|c| c.match_strength.is_none()));
543    }
544
545    #[test]
546    fn empty_returns_all_brands() {
547        assert_eq!(credit_card_type("").len(), 14);
548        assert!(credit_card_type("")
549            .iter()
550            .all(|c| c.match_strength.is_none()));
551    }
552
553    #[test]
554    fn unknown_prefix_is_empty() {
555        assert!(credit_card_type("9999").is_empty());
556    }
557
558    #[test]
559    fn get_type_info_works() {
560        let amex = get_type_info("american-express").unwrap();
561        assert_eq!(amex.nice_type, "American Express");
562        assert_eq!(amex.code.size, 4);
563        assert_eq!(amex.lengths, &[15]);
564        assert!(get_type_info("not-a-brand").is_none());
565    }
566
567    #[test]
568    fn metadata_is_exposed() {
569        let visa = &credit_card_type("4")[0];
570        assert_eq!(visa.gaps, &[4, 8, 12]);
571        assert_eq!(visa.lengths, &[16, 18, 19]);
572        assert_eq!(
573            visa.code,
574            Code {
575                name: "CVV",
576                size: 3
577            }
578        );
579    }
580}