use super::funejson::Value; /// Letter values from 49 CFR 565.15, indexed by letter A-Z. I, O and Q are /// never used and hold -1; the others run 1-9 in three passes (A-I, J-R, S-Z) /// with the forbidden letters' slots skipped. const LETTER_VALUES: [i64; 26] = [1, 2, 3, 4, 5, 6, 7, 8, -1, 1, 2, 3, 4, 5, -1, 7, -1, 9, 2, 3, 4, 5, 6, 7, 8, 9]; /// Position weights; position 9 is the check digit itself and weighs nothing. const WEIGHTS: [i64; 17] = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; fn invalid(reason: &str) -> VinCheck { VinCheck { valid: false, normalised: None, wmi: None, check_digit_matches: None, reason: Some(reason.to_string()), } } /// Check a 17-character VIN. /// /// The check digit is enforced only for North American VINs (first character /// 1-5), where the law requires it; elsewhere a mismatch is reported in /// `check_digit_matches` but does not make the VIN invalid, because many /// European and Asian VINs never carried one. Validators answer rather than /// panic. pub fn validate_vin(value: &str) -> VinCheck { let vin: Vec = value .chars() .filter(|ch| *ch != ' ') .map(|ch| ch.to_ascii_uppercase()) .collect(); if vin.is_empty() { return invalid("empty"); } if vin.iter().any(|ch| !(ch.is_ascii_digit() || ch.is_ascii_uppercase())) { return invalid("bad-character"); } if vin.len() != 17 { return invalid("bad-length"); } let mut total: i64 = 0; for (i, ch) in vin.iter().enumerate() { let n = if ch.is_ascii_digit() { *ch as i64 - 48 } else { let n = LETTER_VALUES[(*ch as u8 - b'A') as usize]; // I, O and Q read as 1, 0 and 0, so the standard never issues them. if n < 0 { return invalid("forbidden-letter"); } n }; total += n * WEIGHTS[i]; } let remainder = total % 11; let expected = if remainder == 10 { 'X' } else { char::from_digit(remainder as u32, 10).unwrap() }; let matches = vin[8] == expected; // WMIs starting 1-5 are North American, where 49 CFR 565 makes the check // digit mandatory; anywhere else it is optional, so only report it. if !matches && ('1'..='5').contains(&vin[0]) { return invalid("bad-check-digit"); } VinCheck { valid: true, normalised: Some(vin.iter().collect()), wmi: Some(vin[..3].iter().collect()), check_digit_matches: Some(matches), reason: None, } } /// Object keys are camelCase to match the shared vectors. pub fn vin_check_to_value(result: &VinCheck) -> Value { let text = |field: &Option| match field { Some(s) => Value::str(s), None => Value::Null, }; Value::obj(vec![ ("valid", Value::Bool(result.valid)), ("normalised", text(&result.normalised)), ("wmi", text(&result.wmi)), ( "checkDigitMatches", match result.check_digit_matches { Some(b) => Value::Bool(b), None => Value::Null, }, ), ("reason", text(&result.reason)), ]) } pub fn fune_vector(args: &[Value]) -> Value { // A non-string argument arrives here as an empty string, which is exactly // the answer TypeScript and Python give for a non-string: empty. vin_check_to_value(&validate_vin(args[0].as_str())) }