use std::collections::BTreeMap;
use std::fmt::Display;
use crate::element;
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct VehicleInfo {
pub make: String,
pub make_id: u32,
pub model: Option<String>,
pub model_id: u32,
pub year: i32,
pub year_conclusive: bool,
pub series: Option<String>,
pub series2: Option<String>,
pub trim: Option<String>,
pub trim2: Option<String>,
pub manufacturer: Option<String>,
pub country: Option<String>,
pub vehicle_type: Option<String>,
pub generation: Option<Generation>,
pub body_style: Option<BodyStyle>,
pub body_class: Option<String>,
pub cab_type: Option<String>,
pub doors: Option<i32>,
pub seats: Option<i32>,
pub seat_rows: Option<i32>,
pub wheels: Option<i32>,
pub steering_location: Option<String>,
pub engine_type: Option<String>,
pub engine_manufacturer: Option<String>,
pub engine_cylinders: Option<i32>,
pub engine_configuration: Option<String>,
pub engine_hp: Option<f64>,
pub displacement_l: Option<f64>,
pub displacement_cc: Option<f64>,
pub displacement_ci: Option<f64>,
pub valve_train_design: Option<String>,
pub fuel_injection_type: Option<String>,
pub turbo: Option<String>,
pub fuel_type: Option<String>,
pub fuel_type_secondary: Option<String>,
pub electrification_level: Option<String>,
pub drive_type: Option<String>,
pub transmission: Option<String>,
pub transmission_speeds: Option<i32>,
pub axles: Option<i32>,
pub brake_system_type: Option<String>,
pub gvwr: Option<String>,
pub gvwr_to: Option<String>,
pub wheel_base_in: Option<f64>,
pub plant_country: Option<String>,
pub plant_state: Option<String>,
pub plant_city: Option<String>,
pub plant_company: Option<String>,
pub abs: Option<String>,
pub esc: Option<String>,
pub traction_control: Option<String>,
pub tpms: Option<String>,
pub backup_camera: Option<String>,
pub forward_collision_warning: Option<String>,
pub lane_departure_warning: Option<String>,
pub lane_keep_assist: Option<String>,
pub blind_spot_monitor: Option<String>,
pub adaptive_cruise_control: Option<String>,
pub daytime_running_light: Option<String>,
pub keyless_ignition: Option<String>,
pub airbag_front: Option<String>,
pub airbag_side: Option<String>,
pub airbag_curtain: Option<String>,
pub airbag_knee: Option<String>,
pub seat_belt_type: Option<String>,
pub attributes: BTreeMap<&'static str, String>,
pub warnings: Vec<Warning>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Generation {
pub ordinal: Option<u8>,
pub code: Option<String>,
pub name: String,
pub year_from: u16,
pub year_to: Option<u16>,
}
impl Generation {
pub fn covers(&self, model_year: i32) -> bool {
model_year >= self.year_from as i32
&& self.year_to.is_none_or(|last| model_year <= last as i32)
}
pub fn span(&self, through: i32) -> i32 {
let last = self.year_to.map_or(through, i32::from);
(last - self.year_from as i32 + 1).max(1)
}
}
impl Display for Generation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.year_to {
Some(last) => write!(f, "{} ({}-{})", self.name, self.year_from, last),
None => write!(f, "{} ({}-)", self.name, self.year_from),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Warning {
CheckDigitMismatch,
ModelYearAmbiguous,
NoSchemaForYear,
NoPatternMatched,
ModelNotFound,
}
impl Display for Warning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let text = match self {
Self::CheckDigitMismatch => "check digit does not match",
Self::ModelYearAmbiguous => "model year could not be established conclusively",
Self::NoSchemaForYear => "no VIN schema covers this model year",
Self::NoPatternMatched => "no VIN pattern matched",
Self::ModelNotFound => "no model pattern matched",
};
write!(f, "{text}")
}
}
impl VehicleInfo {
pub fn get(&self, code: &str) -> Option<&str> {
self.attributes.get(code).map(String::as_str)
}
pub fn get_i32(&self, code: &str) -> Option<i32> {
self.get(code)?.trim().parse().ok()
}
pub fn get_f64(&self, code: &str) -> Option<f64> {
self.get(code)?.trim().parse().ok()
}
pub fn get_bool(&self, code: &str) -> Option<bool> {
let value = self.get(code)?.trim();
match value.to_ascii_lowercase().as_str() {
"standard" | "optional" | "yes" => Some(true),
"not available" | "no" | "not applicable" | "" => Some(false),
_ => None,
}
}
pub(crate) fn promote_named_fields(&mut self) {
macro_rules! text {
($($field:ident <- $code:literal),* $(,)?) => {
$( self.$field = self.attributes.get($code).cloned(); )*
};
}
macro_rules! number {
($($field:ident : $ty:ty = $code:literal),* $(,)?) => {
$( self.$field = self
.attributes
.get($code)
.and_then(|value| value.trim().parse::<$ty>().ok()); )*
};
}
text! {
model <- "Model",
series <- "Series",
series2 <- "Series2",
trim <- "Trim",
trim2 <- "Trim2",
body_class <- "BodyClass",
cab_type <- "BodyCabType",
steering_location <- "SteeringLocation",
engine_type <- "EngineModel",
engine_manufacturer <- "EngineManufacturer",
engine_configuration <- "EngineConfiguration",
valve_train_design <- "ValveTrainDesign",
fuel_injection_type <- "FuelInjectionType",
turbo <- "Turbo",
fuel_type <- "FuelTypePrimary",
fuel_type_secondary <- "FuelTypeSecondary",
electrification_level <- "ElectrificationLevel",
drive_type <- "DriveType",
transmission <- "TransmissionStyle",
brake_system_type <- "BrakeSystemType",
gvwr <- "GVWR",
gvwr_to <- "GVWR_to",
plant_country <- "PlantCountry",
plant_state <- "PlantState",
plant_city <- "PlantCity",
plant_company <- "PlantCompanyName",
abs <- "ABS",
esc <- "ESC",
traction_control <- "TractionControl",
tpms <- "TPMS",
backup_camera <- "RearVisibilitySystem",
forward_collision_warning <- "ForwardCollisionWarning",
lane_departure_warning <- "LaneDepartureWarning",
lane_keep_assist <- "LaneKeepSystem",
blind_spot_monitor <- "BlindSpotMon",
adaptive_cruise_control <- "AdaptiveCruiseControl",
daytime_running_light <- "DaytimeRunningLight",
keyless_ignition <- "KeylessIgnition",
airbag_front <- "AirBagLocFront",
airbag_side <- "AirBagLocSide",
airbag_curtain <- "AirBagLocCurtain",
airbag_knee <- "AirBagLocKnee",
seat_belt_type <- "SeatBeltsAll",
}
number! {
doors: i32 = "Doors",
seats: i32 = "Seats",
seat_rows: i32 = "SeatRows",
wheels: i32 = "Wheels",
engine_cylinders: i32 = "EngineCylinders",
transmission_speeds: i32 = "TransmissionSpeeds",
axles: i32 = "Axles",
engine_hp: f64 = "EngineHP",
displacement_l: f64 = "DisplacementL",
displacement_cc: f64 = "DisplacementCC",
displacement_ci: f64 = "DisplacementCI",
wheel_base_in: f64 = "WheelBaseShort",
}
self.body_style = self.body_class.as_deref().map(BodyStyle::classify);
}
pub fn is_electrified(&self) -> bool {
if self
.electrification_level
.as_deref()
.is_some_and(|level| !level.eq_ignore_ascii_case("not applicable"))
{
return true;
}
[
self.fuel_type.as_deref(),
self.fuel_type_secondary.as_deref(),
]
.into_iter()
.flatten()
.any(|fuel| fuel.to_ascii_lowercase().contains("electric"))
}
pub fn is_passenger_vehicle(&self) -> bool {
matches!(
self.vehicle_type.as_deref(),
Some("Passenger Car" | "Truck" | "Multipurpose Passenger Vehicle (MPV)")
)
}
pub fn attribute_count(&self) -> usize {
self.attributes.len()
}
pub(crate) fn set_attribute(&mut self, element_id: u16, value: &str) {
let Some(code) = element::code_of(element_id) else {
return;
};
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("not applicable") {
return;
}
self.attributes.insert(code, value.to_string());
}
}
fn squash(value: &str) -> String {
value
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum BodyStyle {
Sedan,
Coupe,
Convertible,
Hatchback,
Wagon,
Suv,
Van,
Minivan,
Pickup,
Truck,
Trailer,
Tractor,
Bus,
Motorcycle,
Limousine,
Incomplete,
Other,
}
impl BodyStyle {
pub fn classify(body_class: &str) -> BodyStyle {
let key = squash(body_class);
let exact = match key.as_str() {
"sedansaloon" => Some(Self::Sedan),
"coupe" => Some(Self::Coupe),
"convertiblecabriolet" | "roadster" => Some(Self::Convertible),
"hatchbackliftbacknotchback" => Some(Self::Hatchback),
"wagon" => Some(Self::Wagon),
"sportutilityvehiclesuvmultipurposevehiclempv" | "crossoverutilityvehiclecuv" => {
Some(Self::Suv)
}
"sportutilitytrucksut" | "pickup" => Some(Self::Pickup),
"minivan" => Some(Self::Minivan),
"van" | "cargovan" | "stepvanwalkinvan" => Some(Self::Van),
"truck" => Some(Self::Truck),
"trucktractor" => Some(Self::Tractor),
"trailer" => Some(Self::Trailer),
"limousine" => Some(Self::Limousine),
"streetcartrolley" => Some(Self::Bus),
_ => None,
};
if let Some(style) = exact {
return style;
}
if let Some(style) = [
Self::Sedan,
Self::Coupe,
Self::Convertible,
Self::Hatchback,
Self::Wagon,
Self::Suv,
Self::Van,
Self::Minivan,
Self::Pickup,
Self::Truck,
Self::Trailer,
Self::Tractor,
Self::Bus,
Self::Motorcycle,
Self::Limousine,
Self::Incomplete,
Self::Other,
]
.into_iter()
.find(|style| squash(&style.to_string()) == key)
{
return style;
}
if key.starts_with("motorcycle") {
return Self::Motorcycle;
}
if key.starts_with("incomplete") {
return Self::Incomplete;
}
if key.starts_with("bus") {
return Self::Bus;
}
Self::Other
}
pub fn is_car_like(&self) -> bool {
matches!(
self,
Self::Sedan
| Self::Coupe
| Self::Convertible
| Self::Hatchback
| Self::Wagon
| Self::Suv
| Self::Van
| Self::Minivan
| Self::Pickup
| Self::Limousine
)
}
}
impl From<&str> for BodyStyle {
fn from(value: &str) -> Self {
Self::classify(value)
}
}
impl Display for BodyStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let text = match self {
Self::Sedan => "Sedan",
Self::Coupe => "Coupe",
Self::Convertible => "Convertible",
Self::Hatchback => "Hatchback",
Self::Wagon => "Wagon",
Self::Suv => "Suv",
Self::Van => "Van",
Self::Minivan => "Minivan",
Self::Pickup => "Pickup",
Self::Truck => "Truck",
Self::Trailer => "Trailer",
Self::Tractor => "Tractor",
Self::Bus => "Bus",
Self::Motorcycle => "Motorcycle",
Self::Limousine => "Limousine",
Self::Incomplete => "Incomplete",
Self::Other => "Other",
};
write!(f, "{text}")
}
}
pub fn extract_body_style(raw_body_style: &str) -> BodyStyle {
BodyStyle::classify(raw_body_style)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_covers_the_common_car_shapes() {
for (raw, expected) in [
("Sedan/Saloon", BodyStyle::Sedan),
("Coupe", BodyStyle::Coupe),
("Convertible/Cabriolet", BodyStyle::Convertible),
("Roadster", BodyStyle::Convertible),
("Hatchback/Liftback/Notchback", BodyStyle::Hatchback),
("Wagon", BodyStyle::Wagon),
("Crossover Utility Vehicle (CUV)", BodyStyle::Suv),
("Minivan", BodyStyle::Minivan),
("Cargo Van", BodyStyle::Van),
("Pickup", BodyStyle::Pickup),
("Sport Utility Truck (SUT)", BodyStyle::Pickup),
("Limousine", BodyStyle::Limousine),
] {
assert_eq!(BodyStyle::classify(raw), expected, "{raw}");
}
}
#[test]
fn both_spellings_nhtsa_has_used_map_to_the_same_style() {
for (before, after) in [
(
"Sport Utility Vehicle (SUV)/Multi-Purpose Vehicle (MPV)",
"Sport Utility Vehicle [SUV]/Multipurpose Vehicle [MPV]",
),
(
"Crossover Utility Vehicle (CUV)",
"Crossover Utility Vehicle [CUV]",
),
("Sport Utility Truck (SUT)", "Sport Utility Truck [SUT]"),
] {
assert_eq!(
BodyStyle::classify(before),
BodyStyle::classify(after),
"{after}"
);
assert_ne!(BodyStyle::classify(after), BodyStyle::Other, "{after}");
}
}
#[test]
fn classify_collapses_the_prefixed_families() {
assert_eq!(
BodyStyle::classify("Motorcycle - Scooter"),
BodyStyle::Motorcycle
);
assert_eq!(BodyStyle::classify("Bus - School Bus"), BodyStyle::Bus);
assert_eq!(
BodyStyle::classify("Incomplete - Chassis Cab (Single Cab)"),
BodyStyle::Incomplete
);
assert_eq!(
BodyStyle::classify("Off-road Vehicle - Snowmobile"),
BodyStyle::Other
);
}
#[test]
fn classify_is_deterministic_for_ambiguous_looking_input() {
assert_eq!(BodyStyle::classify("Truck"), BodyStyle::Truck);
assert_eq!(BodyStyle::classify("Truck-Tractor"), BodyStyle::Tractor);
for _ in 0..64 {
assert_eq!(
BodyStyle::classify("Hatchback/Liftback/Notchback"),
BodyStyle::Hatchback
);
}
}
#[test]
fn classify_round_trips_its_own_display_output() {
for style in [
BodyStyle::Sedan,
BodyStyle::Coupe,
BodyStyle::Convertible,
BodyStyle::Hatchback,
BodyStyle::Wagon,
BodyStyle::Suv,
BodyStyle::Van,
BodyStyle::Minivan,
BodyStyle::Pickup,
BodyStyle::Truck,
BodyStyle::Trailer,
BodyStyle::Tractor,
BodyStyle::Bus,
BodyStyle::Motorcycle,
BodyStyle::Limousine,
BodyStyle::Incomplete,
BodyStyle::Other,
] {
assert_eq!(BodyStyle::classify(&style.to_string()), style, "{style}");
}
}
#[test]
fn unknown_body_classes_fall_through_to_other() {
assert_eq!(BodyStyle::classify(""), BodyStyle::Other);
assert_eq!(BodyStyle::classify("Spaceship"), BodyStyle::Other);
}
#[test]
fn a_generation_knows_the_years_it_covers() {
let closed = Generation {
ordinal: Some(10),
code: Some("FC/FK".to_string()),
name: "10th generation".to_string(),
year_from: 2016,
year_to: Some(2021),
};
assert!(closed.covers(2016) && closed.covers(2021));
assert!(!closed.covers(2015) && !closed.covers(2022));
assert_eq!(closed.span(2026), 6);
assert_eq!(closed.to_string(), "10th generation (2016-2021)");
let current = Generation {
ordinal: Some(11),
code: None,
name: "11th generation".to_string(),
year_from: 2022,
year_to: None,
};
assert!(current.covers(2030));
assert_eq!(current.span(2026), 5);
assert_eq!(current.to_string(), "11th generation (2022-)");
}
#[test]
fn set_attribute_drops_placeholders() {
let mut info = VehicleInfo::default();
info.set_attribute(element::DRIVE_TYPE, "Not Applicable");
info.set_attribute(element::SEATS, " ");
info.set_attribute(element::DOORS, " 4 ");
assert_eq!(info.attributes.len(), 1);
assert_eq!(info.get("Doors"), Some("4"));
assert_eq!(info.get_i32("Doors"), Some(4));
}
#[test]
fn is_electrified_reads_both_the_level_and_the_fuel_types() {
let mut info = VehicleInfo::default();
assert!(!info.is_electrified());
info.fuel_type = Some("Gasoline".to_string());
assert!(!info.is_electrified());
info.fuel_type_secondary = Some("Electric".to_string());
assert!(info.is_electrified());
let bev = VehicleInfo {
electrification_level: Some("BEV".to_string()),
..Default::default()
};
assert!(bev.is_electrified());
let plain = VehicleInfo {
electrification_level: Some("Not Applicable".to_string()),
..Default::default()
};
assert!(!plain.is_electrified());
}
}