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    /// Body style category.
132    pub body_class: Option<BodyClass>,
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/// Coarse body-style enumeration the decoder normalizes vPIC strings into.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
180#[allow(missing_docs)]
181pub enum BodyClass {
182    Sedan,
183    Coupe,
184    Hatchback,
185    Wagon,
186    Convertible,
187    Suv,
188    Crossover,
189    Pickup,
190    Van,
191    Minivan,
192    Bus,
193    Truck,
194    Motorcycle,
195    Trailer,
196    Incomplete,
197    Other,
198}
199
200impl BodyClass {
201    /// Parse a free-form vPIC body-style string into one of the coarse enum variants.
202    pub fn parse(s: &str) -> Self {
203        let lc = s.to_ascii_lowercase();
204        match lc.as_str() {
205            x if x.contains("sedan") => BodyClass::Sedan,
206            x if x.contains("coupe") => BodyClass::Coupe,
207            x if x.contains("hatchback") => BodyClass::Hatchback,
208            x if x.contains("wagon") => BodyClass::Wagon,
209            x if x.contains("convertible") || x.contains("cabrio") || x.contains("roadster") => {
210                BodyClass::Convertible
211            }
212            x if x.contains("crossover") || x.contains("cuv") => BodyClass::Crossover,
213            x if x.contains("sport utility") || x.contains("suv") => BodyClass::Suv,
214            x if x.contains("pickup") => BodyClass::Pickup,
215            x if x.contains("minivan") => BodyClass::Minivan,
216            x if x.contains("van") => BodyClass::Van,
217            x if x.contains("bus") => BodyClass::Bus,
218            x if x.contains("truck") => BodyClass::Truck,
219            x if x.contains("motorcycle") || x.contains("motor") => BodyClass::Motorcycle,
220            x if x.contains("trailer") => BodyClass::Trailer,
221            x if x.contains("incomplete") => BodyClass::Incomplete,
222            _ => BodyClass::Other,
223        }
224    }
225}
226
227/// Fuel-type enumeration the decoder normalizes vPIC strings into.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
229#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
230#[allow(missing_docs)]
231pub enum FuelType {
232    Gasoline,
233    Diesel,
234    Electric,
235    Hybrid,
236    PluginHybrid,
237    Ethanol,
238    FlexFuel,
239    Cng,
240    Lng,
241    Lpg,
242    Hydrogen,
243    FuelCell,
244    Methanol,
245    NaturalGas,
246    Other,
247}
248
249impl FuelType {
250    /// Parse a free-form vPIC fuel-type string into one of the enum variants.
251    pub fn parse(s: &str) -> Self {
252        let lc = s.to_ascii_lowercase();
253        match lc.as_str() {
254            x if x.contains("gasoline") => FuelType::Gasoline,
255            x if x.contains("diesel") => FuelType::Diesel,
256            x if x.contains("plug") => FuelType::PluginHybrid,
257            x if x.contains("hybrid") => FuelType::Hybrid,
258            x if x.contains("methanol") || x.contains("m85") => FuelType::Methanol,
259            x if x.contains("e85") || x.contains("ethanol") => FuelType::Ethanol,
260            x if x.contains("flex") || x.contains("ffv") => FuelType::FlexFuel,
261            x if x.contains("cng") || x.contains("compressed natural") => FuelType::Cng,
262            x if x.contains("lng") || x.contains("liquefied natural") => FuelType::Lng,
263            x if x.contains("lpg") || x.contains("propane") => FuelType::Lpg,
264            x if x.contains("fuel cell") => FuelType::FuelCell,
265            x if x.contains("hydrogen") => FuelType::Hydrogen,
266            x if x.contains("electric") => FuelType::Electric,
267            x if x.contains("natural gas") => FuelType::NaturalGas,
268            _ => FuelType::Other,
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn vin_uppercases_input() {
279        let v = Vin::new("1hgcm82633a004352").unwrap();
280        assert_eq!(v.as_str(), "1HGCM82633A004352");
281    }
282
283    #[test]
284    fn vin_section_accessors() {
285        let v = Vin::new("1HGCM82633A004352").unwrap();
286        assert_eq!(v.wmi(), "1HG");
287        assert_eq!(v.vds(), "CM8263");
288        assert_eq!(v.vis(), "3A004352");
289        assert_eq!(v.check_digit(), '3');
290        assert_eq!(v.year_code(), '3');
291        assert_eq!(v.plant_code(), 'A');
292    }
293
294    #[test]
295    fn vin_display_returns_canonical() {
296        let v = Vin::new("1hgcm82633a004352").unwrap();
297        assert_eq!(format!("{}", v), "1HGCM82633A004352");
298    }
299
300    #[test]
301    fn body_class_full_coverage() {
302        assert_eq!(BodyClass::parse("4-Door Sedan"), BodyClass::Sedan);
303        assert_eq!(BodyClass::parse("2-Door Coupe"), BodyClass::Coupe);
304        assert_eq!(BodyClass::parse("Hatchback"), BodyClass::Hatchback);
305        assert_eq!(BodyClass::parse("Station Wagon"), BodyClass::Wagon);
306        assert_eq!(BodyClass::parse("Convertible"), BodyClass::Convertible);
307        assert_eq!(BodyClass::parse("2-Door Cabriolet"), BodyClass::Convertible);
308        assert_eq!(BodyClass::parse("Roadster"), BodyClass::Convertible);
309        assert_eq!(
310            BodyClass::parse("Crossover Utility Vehicle (CUV)"),
311            BodyClass::Crossover
312        );
313        assert_eq!(
314            BodyClass::parse("Sport Utility Vehicle (SUV)"),
315            BodyClass::Suv
316        );
317        assert_eq!(BodyClass::parse("Crew Cab Pickup"), BodyClass::Pickup);
318        assert_eq!(BodyClass::parse("Cargo Van"), BodyClass::Van);
319        assert_eq!(BodyClass::parse("Minivan"), BodyClass::Minivan);
320        assert_eq!(BodyClass::parse("School Bus"), BodyClass::Bus);
321        assert_eq!(BodyClass::parse("Truck"), BodyClass::Truck);
322        assert_eq!(BodyClass::parse("Motorcycle"), BodyClass::Motorcycle);
323        assert_eq!(BodyClass::parse("Trailer"), BodyClass::Trailer);
324        assert_eq!(
325            BodyClass::parse("Incomplete Vehicle"),
326            BodyClass::Incomplete
327        );
328        assert_eq!(BodyClass::parse("Unknown blob"), BodyClass::Other);
329    }
330
331    #[test]
332    fn fuel_type_full_coverage() {
333        assert_eq!(FuelType::parse("Gasoline"), FuelType::Gasoline);
334        assert_eq!(FuelType::parse("Diesel"), FuelType::Diesel);
335        assert_eq!(FuelType::parse("Electric"), FuelType::Electric);
336        assert_eq!(FuelType::parse("Plug-in Hybrid"), FuelType::PluginHybrid);
337        assert_eq!(FuelType::parse("Hybrid"), FuelType::Hybrid);
338        assert_eq!(FuelType::parse("E85"), FuelType::Ethanol);
339        assert_eq!(FuelType::parse("Ethanol (E85)"), FuelType::Ethanol);
340        assert_eq!(
341            FuelType::parse("Flexible Fuel Vehicle (FFV)"),
342            FuelType::FlexFuel
343        );
344        assert_eq!(
345            FuelType::parse("Compressed Natural Gas (CNG)"),
346            FuelType::Cng
347        );
348        assert_eq!(
349            FuelType::parse("Liquefied Natural Gas (LNG)"),
350            FuelType::Lng
351        );
352        assert_eq!(
353            FuelType::parse("Liquefied Petroleum Gas (LPG)"),
354            FuelType::Lpg
355        );
356        assert_eq!(
357            FuelType::parse("Compressed Hydrogen/Hydrogen"),
358            FuelType::Hydrogen
359        );
360        assert_eq!(FuelType::parse("Fuel Cell"), FuelType::FuelCell);
361        assert_eq!(FuelType::parse("Methanol (M85)"), FuelType::Methanol);
362        assert_eq!(FuelType::parse("Unknown"), FuelType::Other);
363    }
364}