creditcard_identifier/
lib.rs

1mod 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
14/// Luhn lookup table for doubling digits
15const LUHN_LOOKUP: [u8; 10] = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9];
16
17/// Pre-compiled regex patterns for better performance
18struct 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
30/// Extract length constraints from regex pattern like (?=.{15}$) or (?=.{13,16}$)
31/// Returns (clean_pattern, min_length, max_length)
32fn 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            // Range format: (?=.{13,16}$)
44            caps.get(3)
45                .and_then(|m| m.as_str().parse::<usize>().ok())
46                .unwrap_or(0)
47        } else {
48            // Exact format: (?=.{15}$)
49            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
79/// Validate a credit card number using the Luhn algorithm
80///
81/// # Arguments
82///
83/// * `number` - Credit card number (digits only)
84///
85/// # Returns
86///
87/// `true` if valid according to Luhn algorithm, `false` otherwise
88pub 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
113/// Find card brand by card number
114///
115/// # Arguments
116///
117/// * `card_number` - Credit card number
118///
119/// # Returns
120///
121/// Brand name or `None` if not found
122pub 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    // Collect all matching brands
131    let matching_brands: Vec<&CompiledBrand> = compiled
132        .iter()
133        .filter(|brand| {
134            // Check length constraint if specified
135            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            // Check pattern match
143            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    // Multiple matches - check priority_over
156    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    // No priority winner found, return first match
168    Some(matching_brands[0].name)
169}
170
171/// Find card brand with detailed information
172///
173/// # Arguments
174///
175/// * `card_number` - Credit card number
176///
177/// # Returns
178///
179/// Detailed brand information or `None` if not found
180pub 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
187/// Check if card number is supported
188///
189/// # Arguments
190///
191/// * `card_number` - Credit card number
192///
193/// # Returns
194///
195/// `true` if supported, `false` otherwise
196pub fn is_supported(card_number: &str) -> bool {
197    find_brand(card_number).is_some()
198}
199
200/// Validate CVV for a brand
201///
202/// # Arguments
203///
204/// * `cvv` - CVV code
205/// * `brand_name` - Brand name (e.g., "visa", "mastercard")
206///
207/// # Returns
208///
209/// `true` if valid, `false` otherwise
210pub 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
223/// Get brand info by name
224///
225/// # Arguments
226///
227/// * `brand_name` - Brand name
228///
229/// # Returns
230///
231/// Brand info or `None`
232pub fn get_brand_info(brand_name: &str) -> Option<&'static Brand> {
233    BRANDS.iter().find(|b| b.name == brand_name)
234}
235
236/// Get detailed brand info by scheme name
237///
238/// # Arguments
239///
240/// * `scheme` - Scheme name (e.g., "visa", "mastercard")
241///
242/// # Returns
243///
244/// Detailed brand info or `None`
245pub fn get_brand_info_detailed(scheme: &str) -> Option<&'static BrandDetailed> {
246    get_brands_detailed().iter().find(|b| b.scheme == scheme)
247}
248
249/// List all supported brands
250///
251/// # Returns
252///
253/// Array of brand names
254pub 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}