use chrono::NaiveDate;
use std::collections::BTreeMap;
use std::str::FromStr;
use super::data::{
AamvaHeader, AamvaLicense, Compliance, Country, EyeColor, HairColor, Height, Sex,
SubfileDesignator, Truncation,
};
use super::error::AamvaError;
const COMPLIANCE: u8 = 0x40;
const DATA_ELEMENT_SEP: u8 = 0x0A;
const RECORD_SEP: u8 = 0x1E;
const SEGMENT_TERM: u8 = 0x0D;
const ANSI_TAG: &[u8] = b"ANSI ";
const MIN_HEADER_LEN: usize = 4 + 5 + 6 + 2 + 2 + 2 + 10;
pub fn parse(payload: &[u8]) -> Result<AamvaLicense, AamvaError> {
if payload.len() < MIN_HEADER_LEN {
return Err(AamvaError::PayloadTooShort {
len: payload.len(),
min: MIN_HEADER_LEN,
});
}
if payload[0] != COMPLIANCE {
return Err(AamvaError::MissingComplianceIndicator);
}
if payload[1..4] != [DATA_ELEMENT_SEP, RECORD_SEP, SEGMENT_TERM] {
return Err(AamvaError::MalformedHeader(
"expected header magic `@\\n\\x1E\\r`".into(),
));
}
if &payload[4..9] != ANSI_TAG {
return Err(AamvaError::MissingAnsiHeader);
}
let iin_bytes = &payload[9..15];
if !iin_bytes.iter().all(u8::is_ascii_digit) {
return Err(AamvaError::MalformedHeader(
"IIN must be 6 ASCII digits".into(),
));
}
let iin = ascii_str(iin_bytes, "IIN")?.to_string();
let aamva_version = ascii_digits(&payload[15..17], "AAMVA version")?;
let jurisdiction_version = ascii_digits(&payload[17..19], "jurisdiction version")?;
let entry_count = ascii_digits(&payload[19..21], "entry count")?;
let mut cursor = 21usize;
let mut designators = Vec::with_capacity(entry_count as usize);
for index in 0..entry_count as usize {
if cursor + 10 > payload.len() {
return Err(AamvaError::MalformedSubfileDesignator { index });
}
let subfile_type = ascii_str(&payload[cursor..cursor + 2], "subfile type")?.to_string();
let offset = ascii_digits_u32(&payload[cursor + 2..cursor + 6], "subfile offset")? as usize;
let length =
ascii_digits_u32(&payload[cursor + 6..cursor + 10], "subfile length")? as usize;
designators.push(SubfileDesignator {
subfile_type,
offset,
length,
});
cursor += 10;
}
let data_start = cursor;
let mut elements = BTreeMap::<String, String>::new();
for designator in &designators {
if designator.offset < data_start {
return Err(AamvaError::SubfileOutOfBounds {
subfile: designator.subfile_type.clone(),
offset: designator.offset,
length: designator.length,
payload_len: payload.len(),
});
}
let end = designator
.offset
.checked_add(designator.length)
.ok_or_else(|| AamvaError::SubfileOutOfBounds {
subfile: designator.subfile_type.clone(),
offset: designator.offset,
length: designator.length,
payload_len: payload.len(),
})?;
if end > payload.len() {
return Err(AamvaError::SubfileOutOfBounds {
subfile: designator.subfile_type.clone(),
offset: designator.offset,
length: designator.length,
payload_len: payload.len(),
});
}
let subfile = &payload[designator.offset..end];
if subfile.len() < 2 || &subfile[..2] != designator.subfile_type.as_bytes() {
return Err(AamvaError::SubfileTypeMismatch {
expected: designator.subfile_type.clone(),
});
}
parse_subfile_elements(&subfile[2..], &mut elements)?;
}
let license = build_license(
elements,
designators,
iin,
aamva_version,
jurisdiction_version,
entry_count,
)?;
Ok(license)
}
fn parse_subfile_elements(
body: &[u8],
elements: &mut BTreeMap<String, String>,
) -> Result<(), AamvaError> {
for raw in body.split(|b| *b == DATA_ELEMENT_SEP || *b == SEGMENT_TERM) {
if raw.is_empty() {
continue;
}
if raw.len() < 3 {
continue;
}
let code = std::str::from_utf8(&raw[..3])
.map_err(|_| AamvaError::MalformedHeader("non-UTF8 element code".into()))?
.to_string();
let value = std::str::from_utf8(&raw[3..])
.map_err(|_| AamvaError::MalformedHeader(format!("non-UTF8 value for {code}")))?
.trim_end_matches(['\r', '\n'])
.to_string();
elements.insert(code, value);
}
Ok(())
}
fn build_license(
elements: BTreeMap<String, String>,
subfiles: Vec<SubfileDesignator>,
iin: String,
aamva_version: u8,
jurisdiction_version: u8,
entry_count: u8,
) -> Result<AamvaLicense, AamvaError> {
let get = |code: &str| {
elements
.get(code)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
let get_date = |code: &'static str| -> Result<Option<NaiveDate>, AamvaError> {
match elements.get(code) {
Some(raw) if !raw.trim().is_empty() => Ok(Some(parse_date(raw, code)?)),
_ => Ok(None),
}
};
let parse_enum = |code: &str| -> Option<String> { get(code) };
Ok(AamvaLicense {
header: Some(AamvaHeader {
iin,
aamva_version,
jurisdiction_version,
entry_count,
subfiles,
}),
family_name: get("DCS"),
first_name: get("DAC"),
middle_name: get("DAD"),
name_suffix: get("DCU"),
family_name_truncation: parse_enum("DDE").and_then(|s| Truncation::from_str(&s).ok()),
first_name_truncation: parse_enum("DDF").and_then(|s| Truncation::from_str(&s).ok()),
middle_name_truncation: parse_enum("DDG").and_then(|s| Truncation::from_str(&s).ok()),
document_number: get("DAQ"),
document_discriminator: get("DCF"),
country: parse_enum("DCG").and_then(|s| Country::from_str(&s).ok()),
jurisdiction: get("DAJ"),
date_of_birth: get_date("DBB")?,
issue_date: get_date("DBD")?,
expiry_date: get_date("DBA")?,
card_revision_date: get_date("DDB")?,
under_18_until: get_date("DDH")?,
under_19_until: get_date("DDI")?,
under_21_until: get_date("DDJ")?,
sex: parse_enum("DBC").and_then(|s| Sex::from_str(&s).ok()),
eye_color: parse_enum("DAY").and_then(|s| EyeColor::from_str(&s).ok()),
hair_color: parse_enum("DAZ").and_then(|s| HairColor::from_str(&s).ok()),
height: get("DAU").as_deref().and_then(Height::parse),
weight_lb: get("DAW").as_deref().and_then(|s| s.parse().ok()),
weight_kg: get("DAX").as_deref().and_then(|s| s.parse().ok()),
weight_range: get("DCE").as_deref().and_then(|s| s.parse().ok()),
address_street_1: get("DAG"),
address_street_2: get("DAH"),
city: get("DAI"),
postal_code: get("DAK"),
vehicle_class: get("DCA"),
restrictions: get("DCB"),
endorsements: get("DCD"),
organ_donor: get("DDK").as_deref().map(|s| s == "1"),
veteran: get("DDL").as_deref().map(|s| s == "1"),
compliance: parse_enum("DDA").and_then(|s| Compliance::from_str(&s).ok()),
elements,
})
}
pub(crate) fn parse_date(raw: &str, element: &'static str) -> Result<NaiveDate, AamvaError> {
let s = raw.trim();
if let Ok(d) = NaiveDate::parse_from_str(s, "%m%d%Y") {
return Ok(d);
}
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y%m%d") {
return Ok(d);
}
Err(AamvaError::InvalidDate {
element,
raw: s.to_string(),
})
}
fn ascii_str<'a>(bytes: &'a [u8], field: &str) -> Result<&'a str, AamvaError> {
std::str::from_utf8(bytes)
.map_err(|_| AamvaError::MalformedHeader(format!("non-ASCII {field}")))
}
fn ascii_digits(bytes: &[u8], field: &str) -> Result<u8, AamvaError> {
ascii_str(bytes, field)?
.parse::<u8>()
.map_err(|_| AamvaError::MalformedHeader(format!("non-numeric {field}")))
}
fn ascii_digits_u32(bytes: &[u8], field: &str) -> Result<u32, AamvaError> {
ascii_str(bytes, field)?
.parse::<u32>()
.map_err(|_| AamvaError::MalformedHeader(format!("non-numeric {field}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_payload() -> Vec<u8> {
let dl_body = [
"DAQD12345678",
"DCSDOE",
"DACJOHN",
"DADQUINCY",
"DBB01151990",
"DBD06012022",
"DBA06012030",
"DBC1",
"DAU070 in",
"DAW180",
"DAYBRO",
"DAZBLN",
"DAGMAIN ST 123",
"DAHAPT 4",
"DAIANYTOWN",
"DAJCA",
"DAK902100000",
"DCAA",
"DCBA",
"DCDA",
"DCGUSA",
"DCFABC12345",
"DDEN",
"DDFN",
"DDGN",
"DDAF",
"DDK1",
"DDL0",
]
.join("\n");
let mut subfile = Vec::new();
subfile.extend_from_slice(b"DL");
subfile.extend_from_slice(dl_body.as_bytes());
subfile.push(SEGMENT_TERM);
let iin = b"636014";
let version = b"09";
let jversion = b"00";
let entries = b"01";
let designator_len = 10usize;
let header_prefix_len = 4 + 5 + iin.len() + version.len() + jversion.len() + entries.len();
let subfile_offset = header_prefix_len + designator_len;
let subfile_len = subfile.len();
let designator = format!("DL{:04}{:04}", subfile_offset, subfile_len);
let mut payload = Vec::new();
payload.push(COMPLIANCE);
payload.push(DATA_ELEMENT_SEP);
payload.push(RECORD_SEP);
payload.push(SEGMENT_TERM);
payload.extend_from_slice(ANSI_TAG);
payload.extend_from_slice(iin);
payload.extend_from_slice(version);
payload.extend_from_slice(jversion);
payload.extend_from_slice(entries);
payload.extend_from_slice(designator.as_bytes());
debug_assert_eq!(payload.len(), subfile_offset);
payload.extend_from_slice(&subfile);
payload
}
#[test]
fn parses_fixture() {
let payload = fixture_payload();
let license = parse(&payload).unwrap();
let header = license.header.as_ref().unwrap();
assert_eq!(header.iin, "636014");
assert_eq!(header.aamva_version, 9);
assert_eq!(header.jurisdiction_version, 0);
assert_eq!(header.entry_count, 1);
assert_eq!(header.subfiles[0].subfile_type, "DL");
assert_eq!(license.document_number.as_deref(), Some("D12345678"));
assert_eq!(license.family_name.as_deref(), Some("DOE"));
assert_eq!(license.first_name.as_deref(), Some("JOHN"));
assert_eq!(license.middle_name.as_deref(), Some("QUINCY"));
assert_eq!(
license.date_of_birth,
Some(NaiveDate::from_ymd_opt(1990, 1, 15).unwrap())
);
assert_eq!(
license.expiry_date,
Some(NaiveDate::from_ymd_opt(2030, 6, 1).unwrap())
);
assert_eq!(
license.issue_date,
Some(NaiveDate::from_ymd_opt(2022, 6, 1).unwrap())
);
assert_eq!(license.sex, Some(Sex::Male));
assert_eq!(license.height, Some(Height::Inches(70)));
assert_eq!(license.weight_lb, Some(180));
assert_eq!(license.eye_color, Some(EyeColor::Brown));
assert_eq!(license.hair_color, Some(HairColor::Blond));
assert_eq!(license.address_street_1.as_deref(), Some("MAIN ST 123"));
assert_eq!(license.address_street_2.as_deref(), Some("APT 4"));
assert_eq!(license.city.as_deref(), Some("ANYTOWN"));
assert_eq!(license.jurisdiction.as_deref(), Some("CA"));
assert_eq!(license.postal_code.as_deref(), Some("902100000"));
assert_eq!(license.country, Some(Country::Usa));
assert_eq!(license.document_discriminator.as_deref(), Some("ABC12345"));
assert_eq!(license.compliance, Some(Compliance::Compliant));
assert_eq!(license.organ_donor, Some(true));
assert_eq!(license.veteran, Some(false));
assert_eq!(
license.family_name_truncation,
Some(Truncation::NotTruncated)
);
}
#[test]
fn raw_elements_map_is_populated() {
let license = parse(&fixture_payload()).unwrap();
assert_eq!(
license.elements.get("DAQ").map(String::as_str),
Some("D12345678")
);
assert_eq!(license.elements.get("DCS").map(String::as_str), Some("DOE"));
assert_eq!(
license.elements.get("DBB").map(String::as_str),
Some("01151990")
);
assert!(license.elements.contains_key("DCG"));
}
#[test]
fn rejects_missing_compliance() {
let mut payload = fixture_payload();
payload[0] = b'X';
assert!(matches!(
parse(&payload).unwrap_err(),
AamvaError::MissingComplianceIndicator
));
}
#[test]
fn rejects_missing_ansi_tag() {
let mut payload = fixture_payload();
payload[4] = b'x'; assert!(matches!(
parse(&payload).unwrap_err(),
AamvaError::MissingAnsiHeader
));
}
#[test]
fn rejects_short_payload() {
let err = parse(b"@\n").unwrap_err();
assert!(matches!(err, AamvaError::PayloadTooShort { .. }));
}
#[test]
fn rejects_non_numeric_version() {
let mut payload = fixture_payload();
payload[15] = b'X'; let err = parse(&payload).unwrap_err();
assert!(matches!(err, AamvaError::MalformedHeader(_)));
}
#[test]
fn canadian_date_format_parses() {
let d = parse_date("19900115", "DBB").unwrap();
assert_eq!(d, NaiveDate::from_ymd_opt(1990, 1, 15).unwrap());
}
#[test]
fn us_date_format_parses() {
let d = parse_date("01151990", "DBB").unwrap();
assert_eq!(d, NaiveDate::from_ymd_opt(1990, 1, 15).unwrap());
}
#[test]
fn height_with_cm_unit_parses() {
assert_eq!(Height::parse("178 cm"), Some(Height::Centimetres(178)));
}
#[test]
fn height_without_unit_defaults_to_inches() {
assert_eq!(Height::parse("070"), Some(Height::Inches(70)));
}
#[test]
fn height_with_unknown_unit_is_rejected() {
assert_eq!(Height::parse("070 furlongs"), None);
assert_eq!(Height::parse("178 xx"), None);
}
#[test]
fn rejects_non_digit_iin() {
let mut payload = fixture_payload();
payload[9] = b'X'; let err = parse(&payload).unwrap_err();
assert!(matches!(err, AamvaError::MalformedHeader(_)));
}
#[test]
fn rejects_subfile_offset_inside_header() {
let mut payload = fixture_payload();
payload[23..27].copy_from_slice(b"0000");
let err = parse(&payload).unwrap_err();
assert!(matches!(err, AamvaError::SubfileOutOfBounds { .. }));
}
#[test]
fn eye_color_aliases() {
assert_eq!(EyeColor::from_str("BRO").unwrap(), EyeColor::Brown);
assert_eq!(EyeColor::from_str("BRN").unwrap(), EyeColor::Brown);
assert_eq!(
EyeColor::from_str("ZZZ").unwrap(),
EyeColor::Other("ZZZ".into())
);
}
#[test]
fn sex_codes_parse() {
assert_eq!(Sex::from_str("1").unwrap(), Sex::Male);
assert_eq!(Sex::from_str("2").unwrap(), Sex::Female);
assert_eq!(Sex::from_str("9").unwrap(), Sex::NotSpecified);
assert!(Sex::from_str("3").is_err());
}
#[test]
fn truncation_codes_parse() {
assert_eq!(Truncation::from_str("T").unwrap(), Truncation::Truncated);
assert_eq!(Truncation::from_str("N").unwrap(), Truncation::NotTruncated);
assert_eq!(Truncation::from_str("U").unwrap(), Truncation::Unknown);
}
#[test]
fn enum_display_roundtrips() {
assert_eq!(Sex::Male.to_string(), "1");
assert_eq!(Compliance::Compliant.to_string(), "F");
assert_eq!(Country::Usa.to_string(), "USA");
assert_eq!(Truncation::Truncated.to_string(), "T");
}
}