Skip to main content

vin_decode/
types.rs

1use std::fmt;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// A validated 17-character VIN.
7///
8/// Construction enforces length, ASCII-alphanumeric chars, and the I/O/Q ban.
9/// Check-digit validation is separate (see [`crate::Decoder::decode`]).
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct Vin(String);
13
14impl Vin {
15    /// Parse a raw VIN string. Uppercases ASCII; rejects bad length/chars.
16    pub fn new(raw: impl Into<String>) -> crate::Result<Self> {
17        let s = raw.into().to_ascii_uppercase();
18        crate::wmi::validate_chars(&s)?;
19        Ok(Vin(s))
20    }
21
22    /// Borrow as canonical (uppercase) string slice.
23    pub fn as_str(&self) -> &str {
24        &self.0
25    }
26
27    /// World Manufacturer Identifier — first 3 chars.
28    pub fn wmi(&self) -> &str {
29        &self.0[..3]
30    }
31
32    /// Vehicle Descriptor Section — chars 4-9.
33    pub fn vds(&self) -> &str {
34        &self.0[3..9]
35    }
36
37    /// Vehicle Identifier Section — chars 10-17.
38    pub fn vis(&self) -> &str {
39        &self.0[9..]
40    }
41
42    /// Check digit at position 9.
43    pub fn check_digit(&self) -> char {
44        self.0.as_bytes()[8] as char
45    }
46
47    /// Model-year code at position 10.
48    pub fn year_code(&self) -> char {
49        self.0.as_bytes()[9] as char
50    }
51
52    /// Plant code at position 11.
53    pub fn plant_code(&self) -> char {
54        self.0.as_bytes()[10] as char
55    }
56
57    /// Region code — first character (ISO 3779 region bucket).
58    pub fn region_code(&self) -> char {
59        self.0.as_bytes()[0] as char
60    }
61
62    /// Country code — first two characters (ISO 3779 country range).
63    pub fn country_code(&self) -> &str {
64        &self.0[..2]
65    }
66
67    /// Squish-VIN — the 10-char fingerprint used by some lookup tools:
68    /// chars 1-8 + chars 10-11 (skipping the check digit at position 9).
69    pub fn squish_vin(&self) -> String {
70        let s = &self.0;
71        let mut out = String::with_capacity(10);
72        out.push_str(&s[..8]);
73        out.push_str(&s[9..11]);
74        out
75    }
76
77    /// Both possible model-year candidates from the VIN's pos-10 year code.
78    ///
79    /// SAE-J853 reuses each letter twice (30-year cycle), so a code like
80    /// `'F'` maps to both 1985 and 2015. This returns both. Numeric codes
81    /// `1`-`9` only ever map to a single year (2001-2009) since the second
82    /// digit cycle (2031-2039) hasn't started. Returns an empty vec for
83    /// unreadable codes (`I`/`O`/`Q`/`U`/`Z`/`0`).
84    ///
85    /// Note: many manufacturers don't follow the SAE-J853 pos-10 convention
86    /// (modern Mercedes encodes year in the chassis serial; some Renault /
87    /// Dacia families use other positions; Ford EU uses pos-11). For those
88    /// VINs this method may return candidates that don't include the actual
89    /// model year. The decoder no longer auto-picks one — consumers can
90    /// inspect the candidates and make their own call.
91    pub fn year_candidates(&self) -> Vec<u32> {
92        let code = self.year_code();
93        let Some(base) = crate::year::year_for_code(code) else {
94            return Vec::new();
95        };
96        if code.is_ascii_digit() {
97            vec![base]
98        } else {
99            vec![base + 30, base]
100        }
101    }
102}
103
104impl fmt::Display for Vin {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(&self.0)
107    }
108}
109
110/// Fully decoded vehicle attributes derived from a single VIN.
111///
112/// Every field is `Option<_>` — vPIC coverage is uneven, especially for
113/// non-US-market vehicles. Always check what you got before unwrapping.
114#[derive(Debug, Clone, Default, PartialEq)]
115#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
116pub struct Vehicle {
117    /// Original VIN string (uppercase).
118    pub vin: String,
119    /// World Manufacturer Identifier (first 3 chars of the VIN).
120    pub wmi: String,
121    /// Make name (e.g. `"Honda"`).
122    pub make: Option<String>,
123    /// Model name (e.g. `"Civic"`).
124    pub model: Option<String>,
125    /// Series identifier (sometimes used as a finer model variant).
126    pub series: Option<String>,
127    /// Trim level / package.
128    pub trim: Option<String>,
129    /// Model year (1980-2039, decoded from year code + position-7 disambiguator).
130    pub model_year: Option<u32>,
131    /// Bodywork type, per the EU type-approval standard (see [`BodyType`]).
132    pub body_type: Option<BodyType>,
133    /// Primary fuel type.
134    pub fuel_primary: Option<FuelType>,
135    /// Secondary fuel type (set on hybrids, dual-fuel).
136    pub fuel_secondary: Option<FuelType>,
137    /// Door count.
138    pub doors: Option<u8>,
139    /// Engine cylinder count.
140    pub engine_cylinders: Option<u8>,
141    /// Engine model designation.
142    pub engine_model: Option<String>,
143    /// Engine configuration (e.g. `"V"`, `"In-Line"`).
144    pub engine_configuration: Option<String>,
145    /// Engine manufacturer.
146    pub engine_manufacturer: Option<String>,
147    /// Displacement in liters.
148    pub displacement_l: Option<f32>,
149    /// Whether the engine is turbocharged.
150    pub turbo: Option<bool>,
151    /// Drive type (e.g. `"FWD"`, `"AWD"`).
152    pub drive_type: Option<String>,
153    /// Transmission style.
154    pub transmission: Option<String>,
155    /// Battery type (EV / hybrid).
156    pub battery_type: Option<String>,
157    /// On-board charger level (EV).
158    pub charger_level: Option<String>,
159    /// EV drive unit configuration.
160    pub ev_drive_unit: Option<String>,
161    /// Brake system type.
162    pub brake_system: Option<String>,
163    /// Gross vehicle weight rating.
164    pub gvwr: Option<String>,
165    /// Plant country.
166    pub plant_country: Option<String>,
167    /// Plant city.
168    pub plant_city: Option<String>,
169    /// Plant state/province.
170    pub plant_state: Option<String>,
171    /// Manufacturer name (often differs from make for OEM/coachbuilders).
172    pub manufacturer: Option<String>,
173    /// Continental region derived from the first VIN character (Africa/Asia/Europe/etc).
174    pub region: Option<String>,
175}
176
177/// Vehicle bodywork type, per the EU type-approval standard — the two-letter
178/// codes from Commission Regulation (EU) 678/2011 (consolidated into the
179/// framework Reg (EU) 2018/858), the same codes printed on the Certificate of
180/// Conformity and recorded by EU national vehicle registers.
181///
182/// This is a *bodywork* classification, not a market segment. There is
183/// deliberately **no `SUV` / `Crossover`** variant: the standard has none — an
184/// SUV is type-approved as a [`BodyType::StationWagon`] (`AC`) or
185/// [`BodyType::MultiPurpose`] (`AF`). Segment labelling (SUV, city car, …) is
186/// an application-layer concern and is intentionally out of scope.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
188#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
189#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
190#[allow(missing_docs)]
191pub enum BodyType {
192    // Category M1 — passenger cars
193    Saloon,            // AA
194    Hatchback,         // AB
195    StationWagon,      // AC
196    Coupe,             // AD
197    Convertible,       // AE
198    MultiPurpose,      // AF
199    TruckStationWagon, // AG
200    // Category N — goods vehicles
201    Lorry,       // BA
202    Van,         // BB
203    TractorUnit, // BC
204    RoadTractor, // BD
205    PickUp,      // BE
206    ChassisCab,  // BX
207    // Category M2/M3 — buses & coaches
208    SingleDeckBus,                    // CA
209    DoubleDeckBus,                    // CB
210    ArticulatedSingleDeckBus,         // CC
211    ArticulatedDoubleDeckBus,         // CD
212    LowFloorSingleDeckBus,            // CE
213    LowFloorDoubleDeckBus,            // CF
214    ArticulatedLowFloorSingleDeckBus, // CG
215    ArticulatedLowFloorDoubleDeckBus, // CH
216    OpenTopSingleDeckBus,             // CI
217    OpenTopDoubleDeckBus,             // CJ
218    BusChassis,                       // CX
219    // Category O — trailers
220    SemiTrailer,         // DA
221    DrawbarTrailer,      // DB
222    CentreAxleTrailer,   // DC
223    RigidDrawbarTrailer, // DE
224    // Special-purpose vehicles
225    MotorCaravan,           // SA
226    ArmouredVehicle,        // SB
227    Ambulance,              // SC
228    Hearse,                 // SD
229    TrailerCaravan,         // SE
230    MobileCrane,            // SF
231    SpecialGroup,           // SG
232    WheelchairAccessible,   // SH
233    ConverterDolly,         // SJ
234    ExceptionalLoadTrailer, // SK
235    /// Bodywork that maps to no EU type-approval code — e.g. a category L
236    /// powered two-wheeler, or an unrecognised source string.
237    Unknown,
238}
239
240/// Every standard (code-bearing) variant, in EU-code order. Excludes
241/// [`BodyType::Unknown`], which is the absence of a code rather than a type.
242pub(crate) const BODY_TYPES: &[BodyType] = &[
243    BodyType::Saloon,
244    BodyType::Hatchback,
245    BodyType::StationWagon,
246    BodyType::Coupe,
247    BodyType::Convertible,
248    BodyType::MultiPurpose,
249    BodyType::TruckStationWagon,
250    BodyType::Lorry,
251    BodyType::Van,
252    BodyType::TractorUnit,
253    BodyType::RoadTractor,
254    BodyType::PickUp,
255    BodyType::ChassisCab,
256    BodyType::SingleDeckBus,
257    BodyType::DoubleDeckBus,
258    BodyType::ArticulatedSingleDeckBus,
259    BodyType::ArticulatedDoubleDeckBus,
260    BodyType::LowFloorSingleDeckBus,
261    BodyType::LowFloorDoubleDeckBus,
262    BodyType::ArticulatedLowFloorSingleDeckBus,
263    BodyType::ArticulatedLowFloorDoubleDeckBus,
264    BodyType::OpenTopSingleDeckBus,
265    BodyType::OpenTopDoubleDeckBus,
266    BodyType::BusChassis,
267    BodyType::SemiTrailer,
268    BodyType::DrawbarTrailer,
269    BodyType::CentreAxleTrailer,
270    BodyType::RigidDrawbarTrailer,
271    BodyType::MotorCaravan,
272    BodyType::ArmouredVehicle,
273    BodyType::Ambulance,
274    BodyType::Hearse,
275    BodyType::TrailerCaravan,
276    BodyType::MobileCrane,
277    BodyType::SpecialGroup,
278    BodyType::WheelchairAccessible,
279    BodyType::ConverterDolly,
280    BodyType::ExceptionalLoadTrailer,
281];
282
283impl BodyType {
284    /// The canonical two-letter EU type-approval bodywork code (`"AA"`, `"BB"`,
285    /// …). [`BodyType::Unknown`] has no code and returns `""`.
286    pub fn code(self) -> &'static str {
287        match self {
288            BodyType::Saloon => "AA",
289            BodyType::Hatchback => "AB",
290            BodyType::StationWagon => "AC",
291            BodyType::Coupe => "AD",
292            BodyType::Convertible => "AE",
293            BodyType::MultiPurpose => "AF",
294            BodyType::TruckStationWagon => "AG",
295            BodyType::Lorry => "BA",
296            BodyType::Van => "BB",
297            BodyType::TractorUnit => "BC",
298            BodyType::RoadTractor => "BD",
299            BodyType::PickUp => "BE",
300            BodyType::ChassisCab => "BX",
301            BodyType::SingleDeckBus => "CA",
302            BodyType::DoubleDeckBus => "CB",
303            BodyType::ArticulatedSingleDeckBus => "CC",
304            BodyType::ArticulatedDoubleDeckBus => "CD",
305            BodyType::LowFloorSingleDeckBus => "CE",
306            BodyType::LowFloorDoubleDeckBus => "CF",
307            BodyType::ArticulatedLowFloorSingleDeckBus => "CG",
308            BodyType::ArticulatedLowFloorDoubleDeckBus => "CH",
309            BodyType::OpenTopSingleDeckBus => "CI",
310            BodyType::OpenTopDoubleDeckBus => "CJ",
311            BodyType::BusChassis => "CX",
312            BodyType::SemiTrailer => "DA",
313            BodyType::DrawbarTrailer => "DB",
314            BodyType::CentreAxleTrailer => "DC",
315            BodyType::RigidDrawbarTrailer => "DE",
316            BodyType::MotorCaravan => "SA",
317            BodyType::ArmouredVehicle => "SB",
318            BodyType::Ambulance => "SC",
319            BodyType::Hearse => "SD",
320            BodyType::TrailerCaravan => "SE",
321            BodyType::MobileCrane => "SF",
322            BodyType::SpecialGroup => "SG",
323            BodyType::WheelchairAccessible => "SH",
324            BodyType::ConverterDolly => "SJ",
325            BodyType::ExceptionalLoadTrailer => "SK",
326            BodyType::Unknown => "",
327        }
328    }
329
330    /// Map a two-letter EU type-approval code (case-insensitive) onto a variant.
331    /// Returns `None` for an unrecognised code. This is the exact path for
332    /// registry data that already carries the standard code (e.g. RDW
333    /// `carrosserietype`).
334    pub fn from_code(code: &str) -> Option<Self> {
335        Some(match code.trim().to_ascii_uppercase().as_str() {
336            "AA" => BodyType::Saloon,
337            "AB" => BodyType::Hatchback,
338            "AC" => BodyType::StationWagon,
339            "AD" => BodyType::Coupe,
340            "AE" => BodyType::Convertible,
341            "AF" => BodyType::MultiPurpose,
342            "AG" => BodyType::TruckStationWagon,
343            "BA" => BodyType::Lorry,
344            "BB" => BodyType::Van,
345            "BC" => BodyType::TractorUnit,
346            "BD" => BodyType::RoadTractor,
347            "BE" => BodyType::PickUp,
348            "BX" => BodyType::ChassisCab,
349            "CA" => BodyType::SingleDeckBus,
350            "CB" => BodyType::DoubleDeckBus,
351            "CC" => BodyType::ArticulatedSingleDeckBus,
352            "CD" => BodyType::ArticulatedDoubleDeckBus,
353            "CE" => BodyType::LowFloorSingleDeckBus,
354            "CF" => BodyType::LowFloorDoubleDeckBus,
355            "CG" => BodyType::ArticulatedLowFloorSingleDeckBus,
356            "CH" => BodyType::ArticulatedLowFloorDoubleDeckBus,
357            "CI" => BodyType::OpenTopSingleDeckBus,
358            "CJ" => BodyType::OpenTopDoubleDeckBus,
359            "CX" => BodyType::BusChassis,
360            "DA" => BodyType::SemiTrailer,
361            "DB" => BodyType::DrawbarTrailer,
362            "DC" => BodyType::CentreAxleTrailer,
363            "DE" => BodyType::RigidDrawbarTrailer,
364            "SA" => BodyType::MotorCaravan,
365            "SB" => BodyType::ArmouredVehicle,
366            "SC" => BodyType::Ambulance,
367            "SD" => BodyType::Hearse,
368            "SE" => BodyType::TrailerCaravan,
369            "SF" => BodyType::MobileCrane,
370            "SG" => BodyType::SpecialGroup,
371            "SH" => BodyType::WheelchairAccessible,
372            "SJ" => BodyType::ConverterDolly,
373            "SK" => BodyType::ExceptionalLoadTrailer,
374            _ => return None,
375        })
376    }
377
378    /// Best-effort map of a free-text body-style string — vPIC English, RDW
379    /// Dutch `inrichting`, autoevolution/DBpedia prose — onto the standard
380    /// variant. Order matters: more specific substrings win first.
381    ///
382    /// Market-segment words with no bodywork code (`"SUV"`, `"crossover"`,
383    /// `"CUV"`) collapse to [`BodyType::MultiPurpose`] (`AF`), the standard's
384    /// closest catch-all for a tall multi-use passenger car. Genuinely
385    /// unrecognised input yields [`BodyType::Unknown`].
386    pub fn parse(s: &str) -> Self {
387        let lc = s.to_ascii_lowercase();
388        let has = |needle: &str| lc.contains(needle);
389        // Special-purpose (most specific first).
390        if has("ambulance") {
391            return BodyType::Ambulance;
392        }
393        if has("hearse") || has("lijkwagen") {
394            return BodyType::Hearse;
395        }
396        if has("motor caravan") || has("motorhome") || has("camper") {
397            return BodyType::MotorCaravan;
398        }
399        if has("armoured") || has("armored") {
400            return BodyType::ArmouredVehicle;
401        }
402        if has("wheelchair") {
403            return BodyType::WheelchairAccessible;
404        }
405        if has("crane") {
406            return BodyType::MobileCrane;
407        }
408        // Trailers & buses.
409        if has("semi-trailer") || has("semitrailer") {
410            return BodyType::SemiTrailer;
411        }
412        if has("trailer") {
413            return BodyType::DrawbarTrailer;
414        }
415        if has("double") && (has("deck") || has("decker")) {
416            return BodyType::DoubleDeckBus;
417        }
418        if has("bus") || has("coach") {
419            return BodyType::SingleDeckBus;
420        }
421        // Goods vehicles.
422        if has("pick-up") || has("pickup") || has("pick up") {
423            return BodyType::PickUp;
424        }
425        if has("tractor unit") || has("semi tractor") {
426            return BodyType::TractorUnit;
427        }
428        if has("road tractor") {
429            return BodyType::RoadTractor;
430        }
431        if has("chassis") {
432            return BodyType::ChassisCab;
433        }
434        // Passenger cars.
435        if has("truck station wagon") {
436            return BodyType::TruckStationWagon;
437        }
438        if has("multi-purpose")
439            || has("multipurpose")
440            || has("mpv")
441            || has("minivan")
442            || has("people carrier")
443            || has("monovolume")
444            // Market-segment words with no bodywork code — closest standard bucket.
445            || has("suv")
446            || has("sport utility")
447            || has("crossover")
448            || has("cuv")
449            || has("off-road")
450        {
451            return BodyType::MultiPurpose;
452        }
453        if has("station wagon")
454            || has("stationwagen")
455            || has("estate")
456            || has("wagon")
457            || has("kombi")
458            || has("combi")
459            || has("touring")
460            || has("avant")
461            || has("variant")
462            || has("break")
463        {
464            return BodyType::StationWagon;
465        }
466        if has("convertible")
467            || has("cabrio")
468            || has("roadster")
469            || has("spider")
470            || has("spyder")
471            || has("targa")
472            || has("drophead")
473        {
474            return BodyType::Convertible;
475        }
476        if has("coupe") || has("coupé") {
477            return BodyType::Coupe;
478        }
479        if has("hatchback")
480            || has("hatch")
481            || has("liftback")
482            || has("fastback")
483            || has("sportback")
484        {
485            return BodyType::Hatchback;
486        }
487        if has("van") || has("gesloten opbouw") {
488            return BodyType::Van;
489        }
490        if has("saloon")
491            || has("sedan")
492            || has("berlina")
493            || has("berline")
494            || has("limousine")
495            || has("notchback")
496        {
497            return BodyType::Saloon;
498        }
499        // Generic "truck" / "lorry" last so "truck station wagon" etc. win above.
500        if has("lorry") || has("truck") {
501            return BodyType::Lorry;
502        }
503        BodyType::Unknown
504    }
505}
506
507/// Fuel-type enumeration the decoder normalizes vPIC strings into.
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
509#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
510#[allow(missing_docs)]
511pub enum FuelType {
512    Gasoline,
513    Diesel,
514    Electric,
515    Hybrid,
516    PluginHybrid,
517    Ethanol,
518    FlexFuel,
519    Cng,
520    Lng,
521    Lpg,
522    Hydrogen,
523    FuelCell,
524    Methanol,
525    NaturalGas,
526    Other,
527}
528
529impl FuelType {
530    /// Parse a free-form vPIC fuel-type string into one of the enum variants.
531    pub fn parse(s: &str) -> Self {
532        let lc = s.to_ascii_lowercase();
533        match lc.as_str() {
534            x if x.contains("gasoline") => FuelType::Gasoline,
535            x if x.contains("diesel") => FuelType::Diesel,
536            x if x.contains("plug") => FuelType::PluginHybrid,
537            x if x.contains("hybrid") => FuelType::Hybrid,
538            x if x.contains("methanol") || x.contains("m85") => FuelType::Methanol,
539            x if x.contains("e85") || x.contains("ethanol") => FuelType::Ethanol,
540            x if x.contains("flex") || x.contains("ffv") => FuelType::FlexFuel,
541            x if x.contains("cng") || x.contains("compressed natural") => FuelType::Cng,
542            x if x.contains("lng") || x.contains("liquefied natural") => FuelType::Lng,
543            x if x.contains("lpg") || x.contains("propane") => FuelType::Lpg,
544            x if x.contains("fuel cell") => FuelType::FuelCell,
545            x if x.contains("hydrogen") => FuelType::Hydrogen,
546            x if x.contains("electric") => FuelType::Electric,
547            x if x.contains("natural gas") => FuelType::NaturalGas,
548            _ => FuelType::Other,
549        }
550    }
551}
552
553/// Drive layout, normalised from the free-text `drive` strings in the engine
554/// catalog (`"Front Wheel Drive"`, `"All Wheel Drive"`, …).
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
556#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
557#[allow(missing_docs)]
558pub enum DriveType {
559    Fwd,
560    Rwd,
561    Awd,
562    FourWheelDrive,
563    Other,
564}
565
566impl DriveType {
567    /// Parse a free-form drive-layout string into a variant.
568    pub fn parse(s: &str) -> Self {
569        let lc = s.to_ascii_lowercase();
570        if lc.contains("front") {
571            DriveType::Fwd
572        } else if lc.contains("rear") {
573            DriveType::Rwd
574        } else if lc.contains("all wheel") || lc.contains("all-wheel") || lc.trim() == "awd" {
575            DriveType::Awd
576        } else if lc.contains("four wheel")
577            || lc.contains("four-wheel")
578            || lc.contains("4wd")
579            || lc.contains("4x4")
580        {
581            DriveType::FourWheelDrive
582        } else {
583            DriveType::Other
584        }
585    }
586}
587
588/// Transmission type, normalised from the free-text `gearbox` strings in the
589/// engine catalog (`"6-Speed Manual"`, `"7-Speed Dual Clutch"`, `"CVT"`, …).
590/// Gear count is separate — see [`crate::EngineRow::gearbox_speeds`].
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
592#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
593#[allow(missing_docs)]
594pub enum Transmission {
595    Manual,
596    Automatic,
597    DualClutch,
598    Cvt,
599    SingleSpeed,
600    AutomatedManual,
601    Other,
602}
603
604impl Transmission {
605    /// Parse a free-form gearbox string into a variant. Order matters: specific
606    /// box types (CVT, dual-clutch, automated-manual) are tested before the
607    /// generic `"manual"` / `"automatic"` substrings.
608    pub fn parse(s: &str) -> Self {
609        let lc = s.to_ascii_lowercase();
610        if lc.contains("cvt") || lc.contains("continuously variable") {
611            Transmission::Cvt
612        } else if lc.contains("single-speed") || lc.contains("single speed") || lc.contains("1-speed")
613        {
614            Transmission::SingleSpeed
615        } else if lc.contains("dual clutch")
616            || lc.contains("dual-clutch")
617            || lc.contains("twin clutch")
618            || lc.contains("dsg")
619            || lc.contains("dct")
620            || lc.contains("dkg")
621            || lc.contains("pdk")
622            || lc.contains("s tronic")
623            || lc.contains("s-tronic")
624            || lc.contains("powershift")
625        {
626            Transmission::DualClutch
627        } else if lc.contains("automated manual")
628            || lc.contains("automatic manual")
629            || lc.contains("automatized")
630            || lc.contains("semi-automatic")
631            || lc.contains("amt")
632        {
633            Transmission::AutomatedManual
634        } else if lc.contains("manual") {
635            Transmission::Manual
636        } else if lc.contains("automatic") || lc.contains("auto") || lc.contains("tiptronic") {
637            Transmission::Automatic
638        } else {
639            Transmission::Other
640        }
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn vin_uppercases_input() {
650        let v = Vin::new("1hgcm82633a004352").unwrap();
651        assert_eq!(v.as_str(), "1HGCM82633A004352");
652    }
653
654    #[test]
655    fn vin_section_accessors() {
656        let v = Vin::new("1HGCM82633A004352").unwrap();
657        assert_eq!(v.wmi(), "1HG");
658        assert_eq!(v.vds(), "CM8263");
659        assert_eq!(v.vis(), "3A004352");
660        assert_eq!(v.check_digit(), '3');
661        assert_eq!(v.year_code(), '3');
662        assert_eq!(v.plant_code(), 'A');
663    }
664
665    #[test]
666    fn vin_display_returns_canonical() {
667        let v = Vin::new("1hgcm82633a004352").unwrap();
668        assert_eq!(format!("{}", v), "1HGCM82633A004352");
669    }
670
671    #[test]
672    fn body_type_parse_standard_mappings() {
673        // vPIC English
674        assert_eq!(BodyType::parse("4-Door Sedan"), BodyType::Saloon);
675        assert_eq!(BodyType::parse("2-Door Coupe"), BodyType::Coupe);
676        assert_eq!(BodyType::parse("Hatchback"), BodyType::Hatchback);
677        assert_eq!(BodyType::parse("Station Wagon"), BodyType::StationWagon);
678        assert_eq!(BodyType::parse("Convertible"), BodyType::Convertible);
679        assert_eq!(BodyType::parse("2-Door Cabriolet"), BodyType::Convertible);
680        assert_eq!(BodyType::parse("Roadster"), BodyType::Convertible);
681        assert_eq!(BodyType::parse("Crew Cab Pickup"), BodyType::PickUp);
682        assert_eq!(BodyType::parse("Cargo Van"), BodyType::Van);
683        // EU / continental / registry vocab
684        assert_eq!(BodyType::parse("Berline"), BodyType::Saloon);
685        assert_eq!(BodyType::parse("Kombi"), BodyType::StationWagon);
686        assert_eq!(BodyType::parse("Avant"), BodyType::StationWagon);
687        assert_eq!(BodyType::parse("stationwagen"), BodyType::StationWagon);
688        assert_eq!(BodyType::parse("gesloten opbouw"), BodyType::Van);
689        // MPV + market-segment words (no bodywork code) collapse to AF
690        assert_eq!(BodyType::parse("Minivan"), BodyType::MultiPurpose);
691        assert_eq!(
692            BodyType::parse("Sport Utility Vehicle (SUV)"),
693            BodyType::MultiPurpose
694        );
695        assert_eq!(
696            BodyType::parse("Crossover Utility Vehicle (CUV)"),
697            BodyType::MultiPurpose
698        );
699        // special-purpose
700        assert_eq!(BodyType::parse("Ambulance"), BodyType::Ambulance);
701        assert_eq!(BodyType::parse("Hearse"), BodyType::Hearse);
702        // category L two-wheelers / junk have no bodywork code
703        assert_eq!(BodyType::parse("Motorcycle"), BodyType::Unknown);
704        assert_eq!(BodyType::parse("Unknown blob"), BodyType::Unknown);
705    }
706
707    #[test]
708    fn body_type_code_roundtrip() {
709        for &b in BODY_TYPES {
710            assert_eq!(BodyType::from_code(b.code()), Some(b), "roundtrip {b:?}");
711        }
712        assert_eq!(BODY_TYPES.len(), 38);
713        assert_eq!(BodyType::from_code("aa"), Some(BodyType::Saloon)); // case-insensitive
714        assert_eq!(BodyType::from_code("ZZ"), None);
715        assert_eq!(BodyType::Unknown.code(), "");
716    }
717
718    #[test]
719    fn drive_type_parse() {
720        assert_eq!(DriveType::parse("Front Wheel Drive"), DriveType::Fwd);
721        assert_eq!(DriveType::parse("Rear Wheel Drive"), DriveType::Rwd);
722        assert_eq!(DriveType::parse("All Wheel Drive"), DriveType::Awd);
723        assert_eq!(
724            DriveType::parse("Four Wheel Drive"),
725            DriveType::FourWheelDrive
726        );
727        assert_eq!(DriveType::parse("4x4"), DriveType::FourWheelDrive);
728        assert_eq!(DriveType::parse(""), DriveType::Other);
729    }
730
731    #[test]
732    fn transmission_parse() {
733        assert_eq!(Transmission::parse("6-Speed Manual"), Transmission::Manual);
734        assert_eq!(
735            Transmission::parse("7-Speed Automatic"),
736            Transmission::Automatic
737        );
738        assert_eq!(
739            Transmission::parse("7-Speed Dual Clutch"),
740            Transmission::DualClutch
741        );
742        assert_eq!(Transmission::parse("6-Speed DSG"), Transmission::DualClutch);
743        assert_eq!(Transmission::parse("7-Speed S tronic"), Transmission::DualClutch);
744        assert_eq!(Transmission::parse("CVT"), Transmission::Cvt);
745        assert_eq!(
746            Transmission::parse("Single-Speed"),
747            Transmission::SingleSpeed
748        );
749        assert_eq!(
750            Transmission::parse("5-Speed Automated Manual"),
751            Transmission::AutomatedManual
752        );
753        assert_eq!(Transmission::parse("Tiptronic"), Transmission::Automatic);
754        assert_eq!(Transmission::parse(""), Transmission::Other);
755    }
756
757    #[test]
758    fn fuel_type_full_coverage() {
759        assert_eq!(FuelType::parse("Gasoline"), FuelType::Gasoline);
760        assert_eq!(FuelType::parse("Diesel"), FuelType::Diesel);
761        assert_eq!(FuelType::parse("Electric"), FuelType::Electric);
762        assert_eq!(FuelType::parse("Plug-in Hybrid"), FuelType::PluginHybrid);
763        assert_eq!(FuelType::parse("Hybrid"), FuelType::Hybrid);
764        assert_eq!(FuelType::parse("E85"), FuelType::Ethanol);
765        assert_eq!(FuelType::parse("Ethanol (E85)"), FuelType::Ethanol);
766        assert_eq!(
767            FuelType::parse("Flexible Fuel Vehicle (FFV)"),
768            FuelType::FlexFuel
769        );
770        assert_eq!(
771            FuelType::parse("Compressed Natural Gas (CNG)"),
772            FuelType::Cng
773        );
774        assert_eq!(
775            FuelType::parse("Liquefied Natural Gas (LNG)"),
776            FuelType::Lng
777        );
778        assert_eq!(
779            FuelType::parse("Liquefied Petroleum Gas (LPG)"),
780            FuelType::Lpg
781        );
782        assert_eq!(
783            FuelType::parse("Compressed Hydrogen/Hydrogen"),
784            FuelType::Hydrogen
785        );
786        assert_eq!(FuelType::parse("Fuel Cell"), FuelType::FuelCell);
787        assert_eq!(FuelType::parse("Methanol (M85)"), FuelType::Methanol);
788        assert_eq!(FuelType::parse("Unknown"), FuelType::Other);
789    }
790}