creditcard_identifier/
lib.rs1mod brands;
2mod brands_detailed;
3
4use regex::Regex;
5use std::sync::OnceLock;
6
7pub use brands::Brand;
8pub use brands::BRANDS;
9pub use brands_detailed::BrandDetailed;
10pub use brands_detailed::Pattern;
11pub use brands_detailed::BinInfo;
12pub use brands_detailed::get_brands as get_brands_detailed;
13
14const LUHN_LOOKUP: [u8; 10] = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9];
16
17struct CompiledBrand {
19 name: &'static str,
20 priority_over: &'static [&'static str],
21 min_length: usize,
22 max_length: usize,
23 regexp_bin: Regex,
24 regexp_full: Regex,
25 regexp_cvv: Regex,
26}
27
28static COMPILED_BRANDS: OnceLock<Vec<CompiledBrand>> = OnceLock::new();
29
30fn extract_length_from_regex(pattern: &str) -> (String, usize, usize) {
33 let re = Regex::new(r"\(\?=\.\{(\d+)(,(\d+))?\}\$\)").unwrap();
34
35 if let Some(caps) = re.captures(pattern) {
36 let clean_pattern = re.replace(pattern, "").to_string();
37
38 let min_len = caps.get(1)
39 .and_then(|m| m.as_str().parse::<usize>().ok())
40 .unwrap_or(0);
41
42 let max_len = if caps.get(2).is_some() {
43 caps.get(3)
45 .and_then(|m| m.as_str().parse::<usize>().ok())
46 .unwrap_or(0)
47 } else {
48 min_len
50 };
51
52 (clean_pattern, min_len, max_len)
53 } else {
54 (pattern.to_string(), 0, 0)
55 }
56}
57
58fn get_compiled_brands() -> &'static Vec<CompiledBrand> {
59 COMPILED_BRANDS.get_or_init(|| {
60 BRANDS
61 .iter()
62 .map(|brand| {
63 let (clean_full, min_len, max_len) = extract_length_from_regex(brand.regexp_full);
64
65 CompiledBrand {
66 name: brand.name,
67 priority_over: brand.priority_over,
68 min_length: min_len,
69 max_length: max_len,
70 regexp_bin: Regex::new(brand.regexp_bin).unwrap(),
71 regexp_full: Regex::new(&clean_full).unwrap(),
72 regexp_cvv: Regex::new(brand.regexp_cvv).unwrap(),
73 }
74 })
75 .collect()
76 })
77}
78
79pub fn luhn(number: &str) -> bool {
89 if number.is_empty() {
90 return false;
91 }
92
93 let mut total = 0u32;
94 let mut x2 = true;
95
96 for ch in number.chars().rev() {
97 let value = match ch.to_digit(10) {
98 Some(d) => d as u8,
99 None => return false,
100 };
101
102 x2 = !x2;
103 total += if x2 {
104 LUHN_LOOKUP[value as usize] as u32
105 } else {
106 value as u32
107 };
108 }
109
110 total % 10 == 0
111}
112
113pub fn find_brand(card_number: &str) -> Option<&'static str> {
123 if card_number.is_empty() {
124 return None;
125 }
126
127 let compiled = get_compiled_brands();
128 let card_len = card_number.len();
129
130 let matching_brands: Vec<&CompiledBrand> = compiled
132 .iter()
133 .filter(|brand| {
134 if brand.min_length > 0 && card_len < brand.min_length {
136 return false;
137 }
138 if brand.max_length > 0 && card_len > brand.max_length {
139 return false;
140 }
141
142 brand.regexp_full.is_match(card_number)
144 })
145 .collect();
146
147 if matching_brands.is_empty() {
148 return None;
149 }
150
151 if matching_brands.len() == 1 {
152 return Some(matching_brands[0].name);
153 }
154
155 let matching_names: std::collections::HashSet<&str> =
157 matching_brands.iter().map(|b| b.name).collect();
158
159 for candidate in &matching_brands {
160 for priority in candidate.priority_over {
161 if matching_names.contains(priority) {
162 return Some(candidate.name);
163 }
164 }
165 }
166
167 Some(matching_brands[0].name)
169}
170
171pub fn find_brand_detailed(card_number: &str) -> Option<&'static BrandDetailed> {
181 let brand_name = find_brand(card_number)?;
182 get_brands_detailed()
183 .iter()
184 .find(|b| b.scheme == brand_name)
185}
186
187pub fn is_supported(card_number: &str) -> bool {
197 find_brand(card_number).is_some()
198}
199
200pub fn validate_cvv(cvv: &str, brand_name: &str) -> bool {
211 if cvv.is_empty() {
212 return false;
213 }
214
215 let compiled = get_compiled_brands();
216 compiled
217 .iter()
218 .find(|brand| brand.name == brand_name)
219 .map(|brand| brand.regexp_cvv.is_match(cvv))
220 .unwrap_or(false)
221}
222
223pub fn get_brand_info(brand_name: &str) -> Option<&'static Brand> {
233 BRANDS.iter().find(|b| b.name == brand_name)
234}
235
236pub fn get_brand_info_detailed(scheme: &str) -> Option<&'static BrandDetailed> {
246 get_brands_detailed().iter().find(|b| b.scheme == scheme)
247}
248
249pub fn list_brands() -> Vec<&'static str> {
255 BRANDS.iter().map(|b| b.name).collect()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn test_luhn() {
264 assert!(luhn("4012001037141112"));
265 assert!(!luhn("4012001037141113"));
266 }
267
268 #[test]
269 fn test_find_brand() {
270 assert_eq!(find_brand("4012001037141112"), Some("visa"));
271 assert_eq!(find_brand("5533798818319497"), Some("mastercard"));
272 assert_eq!(find_brand("1234567890123456"), None);
273 }
274
275 #[test]
276 fn test_is_supported() {
277 assert!(is_supported("4012001037141112"));
278 assert!(!is_supported("1234567890123456"));
279 }
280
281 #[test]
282 fn test_validate_cvv() {
283 assert!(validate_cvv("123", "visa"));
284 assert!(validate_cvv("1234", "amex"));
285 assert!(!validate_cvv("12", "visa"));
286 }
287}