1#![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#[cfg(doctest)]
40#[doc = include_str!("../README.md")]
41struct ReadmeDoctests;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Code {
46 pub name: &'static str,
48 pub size: u8,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct CardType {
55 pub type_: &'static str,
57 pub nice_type: &'static str,
59 pub gaps: &'static [u8],
62 pub lengths: &'static [u8],
64 pub code: Code,
66 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
98pub mod brand {
100 pub const VISA: &str = "visa";
102 pub const MASTERCARD: &str = "mastercard";
104 pub const AMERICAN_EXPRESS: &str = "american-express";
106 pub const DINERS_CLUB: &str = "diners-club";
108 pub const DISCOVER: &str = "discover";
110 pub const JCB: &str = "jcb";
112 pub const UNIONPAY: &str = "unionpay";
114 pub const NARANJA: &str = "naranja";
116 pub const VERVE: &str = "verve";
118 pub const MAESTRO: &str = "maestro";
120 pub const ELO: &str = "elo";
122 pub const MIR: &str = "mir";
124 pub const HIPER: &str = "hiper";
126 pub const HIPERCARD: &str = "hipercard";
128}
129
130use Pattern::{Prefix as P, Range as R};
131
132static 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#[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#[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
449fn 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
468fn 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
481fn 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 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 assert!(names.contains(&"discover"));
539 assert!(names.contains(&"maestro"));
540 assert!(names.len() > 1);
541 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}