#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Class {
Card,
Emails,
}
impl Class {
pub(crate) fn id(self) -> &'static str {
match self {
Self::Card => "card",
Self::Emails => "emails",
}
}
}
pub(crate) const BULK_EMAILS: usize = 10;
const MAX_DIGITS: usize = 19;
const MIN_DIGITS: usize = 13;
const MAX_GROUPS: usize = 5;
const PUBLIC_TEST_CARDS: &[&str] = &[
"4242424242424242",
"4111111111111111",
"4012888888881881",
"4222222222222",
"4000056655665556",
"4000000000000002",
"4000000000000069",
"4000000000000077",
"4000000000000119",
"4000000000000127",
"4000000000000341",
"4000000000003220",
"4000000000009995",
"4000002500003155",
"4000002760003184",
"5555555555554444",
"5105105105105100",
"5454545454545454",
"2223003122003222",
"5200828282828210",
"378282246310005",
"371449635398431",
"378734493671000",
"6011111111111117",
"6011000990139424",
"3056930009020004",
"30569309025904",
"38520000023237",
"36227206271667",
"3566002020360505",
"3530111333300000",
"6200000000000005",
];
pub(crate) fn classes_in(texts: &[&str]) -> Vec<Class> {
let mut found = Vec::new();
if texts.iter().any(|t| has_card_number(t)) {
found.push(Class::Card);
}
if distinct_emails(texts, BULK_EMAILS) >= BULK_EMAILS {
found.push(Class::Emails);
}
found
}
pub(crate) fn has_card_number(text: &str) -> bool {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if is_escape(bytes, i) {
i += 3;
} else if bytes[i].is_ascii_digit() && starts_number(bytes, i) {
match card_at(bytes, i) {
Scan::Card => return true,
Scan::Next(next) => i = next,
}
} else {
i += 1;
}
}
false
}
enum Scan {
Card,
Next(usize),
}
fn starts_number(bytes: &[u8], at: usize) -> bool {
let Some(&before) = at.checked_sub(1).and_then(|i| bytes.get(i)) else {
return true;
};
let escaped = at >= 3 && is_escape(bytes, at - 3);
escaped || !(before.is_ascii_alphanumeric() || before == b'_' || before == b'.')
}
fn is_escape(bytes: &[u8], at: usize) -> bool {
bytes.get(at) == Some(&b'%')
&& bytes
.get(at + 1..at + 3)
.is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit))
}
fn separator_at(bytes: &[u8], at: usize) -> Option<(&[u8], usize)> {
match bytes.get(at)? {
b' ' | b'-' | b'+' => Some((&bytes[at..=at], 1)),
b'%' if bytes.get(at + 1..at + 3) == Some(b"20") => Some((&bytes[at..at + 3], 3)),
_ => None,
}
}
fn card_at(bytes: &[u8], start: usize) -> Scan {
let mut digits = [0u8; MAX_DIGITS];
let mut count = 0;
let mut groups = [0usize; MAX_GROUPS];
let mut group_count = 0;
let mut separator: Option<&[u8]> = None;
let mut second_group = None;
let mut i = start;
loop {
let run = bytes[i..].iter().take_while(|b| b.is_ascii_digit()).count();
if count + run > MAX_DIGITS || group_count == MAX_GROUPS {
return Scan::Next(second_group.unwrap_or(i + run));
}
digits[count..count + run].copy_from_slice(&bytes[i..i + run]);
count += run;
groups[group_count] = run;
group_count += 1;
i += run;
let next = separator_at(bytes, i).filter(|(sep, len)| {
separator.is_none_or(|s| s == *sep)
&& bytes.get(i + len).is_some_and(u8::is_ascii_digit)
});
let Some((sep, len)) = next else { break };
separator = Some(sep);
i += len;
second_group.get_or_insert(i);
}
let ends = bytes
.get(i)
.is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'_'));
let number = &digits[..count];
if ends && printed_layout(&groups[..group_count]) && is_card(number) {
Scan::Card
} else {
Scan::Next(second_group.unwrap_or(i))
}
}
fn printed_layout(groups: &[usize]) -> bool {
match groups {
[_] | [4, 6, 4 | 5] => true,
[4, 4, 4, last] => (1..=4).contains(last),
[4, 4, 4, 4, last] => (1..=3).contains(last),
_ => false,
}
}
fn is_card(number: &[u8]) -> bool {
(MIN_DIGITS..=MAX_DIGITS).contains(&number.len())
&& brand_length(number)
&& luhn(number)
&& !PUBLIC_TEST_CARDS.iter().any(|t| t.as_bytes() == number)
}
fn brand_length(number: &[u8]) -> bool {
let prefix = |len: usize| {
number[..len]
.iter()
.fold(0u32, |acc, d| acc * 10 + u32::from(d - b'0'))
};
let len = number.len();
let long = (16..=19).contains(&len);
match number[0] {
b'4' => matches!(len, 13 | 16 | 19),
b'5' => (51..=55).contains(&prefix(2)) && len == 16,
b'2' => (2221..=2720).contains(&prefix(4)) && len == 16,
b'3' => {
(matches!(prefix(2), 34 | 37) && len == 15)
|| ((3528..=3589).contains(&prefix(4)) && long)
|| (((300..=305).contains(&prefix(3)) || matches!(prefix(2), 36 | 38 | 39))
&& (14..=19).contains(&len))
}
b'6' => {
(prefix(4) == 6011 || (644..=649).contains(&prefix(3)) || matches!(prefix(2), 62 | 65))
&& long
}
_ => false,
}
}
fn luhn(number: &[u8]) -> bool {
let sum: u32 = number
.iter()
.rev()
.enumerate()
.map(|(i, d)| {
let d = u32::from(d - b'0');
match (i % 2 == 1, d * 2) {
(false, _) => d,
(true, doubled) if doubled > 9 => doubled - 9,
(true, doubled) => doubled,
}
})
.sum();
sum.is_multiple_of(10)
}
pub(crate) fn distinct_emails(texts: &[&str], enough: usize) -> usize {
let mut seen: Vec<String> = Vec::new();
for text in texts {
for address in emails(text) {
let address = address.to_ascii_lowercase();
if !seen.contains(&address) {
seen.push(address);
if seen.len() >= enough {
return seen.len();
}
}
}
}
seen.len()
}
fn emails(text: &str) -> impl Iterator<Item = &str> {
let bytes = text.as_bytes();
bytes
.iter()
.enumerate()
.filter(|(_, b)| **b == b'@')
.filter_map(move |(at, _)| {
let local = bytes[..at]
.iter()
.rev()
.take_while(|b| b.is_ascii_alphanumeric() || b"._%+-".contains(b))
.count();
let domain = bytes[at + 1..]
.iter()
.take_while(|b| b.is_ascii_alphanumeric() || b"-.".contains(b))
.count();
let local_part = &text[at - local..at];
let domain_part = text[at + 1..at + 1 + domain].trim_end_matches('.');
(valid_local(local_part) && valid_domain(domain_part))
.then(|| &text[at - local..at + 1 + domain_part.len()])
})
}
fn valid_local(local: &str) -> bool {
!local.is_empty() && !local.starts_with('.') && !local.ends_with('.')
}
fn valid_domain(domain: &str) -> bool {
let tld = domain.rsplit('.').next().unwrap_or_default();
domain.contains('.')
&& domain
.split('.')
.all(|l| !l.is_empty() && !l.starts_with('-') && !l.ends_with('-'))
&& tld.len() >= 2
&& tld.bytes().all(|b| b.is_ascii_alphabetic())
}
#[cfg(test)]
mod tests;