1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::data::{LookupRow, MakeRow, SchemaRow, VinRuleRow};
5use crate::element::Element;
6use crate::maps::{FstMap, data_dir};
7use crate::types::{Vehicle, Vin};
8use crate::{Error, check_digit, pattern};
9
10#[cfg(feature = "parallel")]
11use rayon::prelude::*;
12
13pub struct Decoder {
18 wmi_make: FstMap<MakeRow>,
19 wmi_schema: FstMap<SchemaRow>,
20 schema_lookup: FstMap<LookupRow>,
21 wmi_rules: Option<FstMap<VinRuleRow>>,
22}
23
24impl Decoder {
25 pub fn new() -> crate::Result<Self> {
35 let dir = data_dir();
36 #[cfg(feature = "embedded")]
37 crate::embedded::ensure_installed(&dir).ok();
38 Self::open(&dir).map_err(|e| match e {
39 Error::MissingData(path) => Error::MissingData(format!(
40 "{path} — set VIN_DECODE_DATA_DIR or install embedded data"
41 )),
42 other => other,
43 })
44 }
45
46 pub fn open(dir: &Path) -> crate::Result<Self> {
49 let wmi_rules = if dir.join("wmi_rules.fst").exists() {
50 Some(FstMap::open(dir)?)
51 } else {
52 None
53 };
54 Ok(Decoder {
55 wmi_make: FstMap::open(dir)?,
56 wmi_schema: FstMap::open(dir)?,
57 schema_lookup: FstMap::open(dir)?,
58 wmi_rules,
59 })
60 }
61
62 pub fn decode(&self, raw: &str) -> crate::Result<Vehicle> {
64 let vin = Vin::new(raw)?;
65 check_digit::validate(&vin)?;
66 Ok(self.decode_inner(vin))
67 }
68
69 pub fn decode_unchecked(&self, raw: &str) -> crate::Result<Vehicle> {
73 let vin = Vin::new(raw)?;
74 Ok(self.decode_inner(vin))
75 }
76
77 pub fn decode_batch(&self, vins: &[&str]) -> Vec<crate::Result<Vehicle>>
79 where
80 Self: Sync,
81 {
82 #[cfg(feature = "parallel")]
83 {
84 vins.par_iter().map(|v| self.decode(v)).collect()
85 }
86 #[cfg(not(feature = "parallel"))]
87 {
88 vins.iter().map(|v| self.decode(v)).collect()
89 }
90 }
91
92 fn decode_inner(&self, vin: Vin) -> Vehicle {
93 let wmi = vin.wmi().to_string();
94 let mut make_row = self.wmi_make.get(&wmi).and_then(|mut rows| rows.pop());
95
96 let mut vehicle = Vehicle {
97 vin: vin.as_str().to_string(),
98 wmi: wmi.clone(),
99 make: make_row.as_ref().map(|r| ascii_fold(&r.name)),
100 ..Default::default()
101 };
102
103 if let Some(row) = make_row.take() {
104 if !row.country.is_empty() {
105 vehicle.plant_country = Some(row.country);
106 }
107 if !row.region.is_empty() {
108 vehicle.region = Some(row.region);
109 }
110 }
111 if vehicle.region.is_none() {
112 if let Some(region) = crate::wmi::region(vin.as_str().chars().next().unwrap_or('\0')) {
113 vehicle.region = Some(region.to_string());
114 }
115 }
116
117 self.apply_wmi_rules(&vin, &mut vehicle);
121
122 self.fill_pattern_attrs(&vin, &mut vehicle);
129
130 if let Some(country) = crate::country::country_from_code(vin.country_code()) {
138 vehicle.plant_country = Some(country.to_string());
139 }
140
141 vehicle
142 }
143
144 fn apply_wmi_rules(&self, vin: &Vin, vehicle: &mut Vehicle) {
148 let Some(wmi_rules) = &self.wmi_rules else {
149 return;
150 };
151 let Some(rules) = wmi_rules.get(vin.wmi()) else {
152 return;
153 };
154 let after_wmi = &vin.as_str()[3..];
155 for rule in rules {
156 if !after_wmi.starts_with(&rule.remainder) {
157 continue;
158 }
159 if !rule.make.is_empty() {
160 vehicle.make = Some(rule.make.clone());
161 }
162 if !rule.model.is_empty() {
163 vehicle.model = Some(rule.model.clone());
164 }
165 return;
166 }
167 }
168
169 fn fill_pattern_attrs(&self, vin: &Vin, vehicle: &mut Vehicle) {
170 let Some(schemas) = self.wmi_schema.get(vin.wmi()) else {
171 return;
172 };
173 let mut groups: HashMap<Element, Vec<(LookupRow, f64)>> = HashMap::new();
174 for sch in &schemas {
175 let Some(lookups) = self.schema_lookup.get(&sch.id) else {
176 continue;
177 };
178 for lk in lookups {
179 let Some(elem) = Element::from_str(&lk.element) else {
180 continue;
181 };
182 let conf = pattern::confidence(&lk.pattern, vin.vds(), vin.vis());
183 if conf > elem.confidence_cutoff() {
184 groups.entry(elem).or_default().push((lk, conf));
185 }
186 }
187 }
188 for (elem, mut group) in groups {
189 group.sort_by(rank_candidates);
190 if let Some((top, _)) = group.into_iter().next() {
191 elem.apply(vehicle, top.value);
192 }
193 }
194 }
195}
196
197fn rank_candidates(a: &(LookupRow, f64), b: &(LookupRow, f64)) -> std::cmp::Ordering {
209 use std::cmp::Ordering;
210 b.0.weight
211 .cmp(&a.0.weight)
212 .then(b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal))
213 .then_with(|| looks_like_fk(&a.0.value).cmp(&looks_like_fk(&b.0.value)))
214 .then_with(|| a.0.value.cmp(&b.0.value))
215}
216
217fn looks_like_fk(value: &str) -> bool {
220 !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit())
221}
222
223pub(crate) fn normalize_make(s: &str) -> String {
228 let mut out = String::with_capacity(s.len());
229 for ch in s.chars() {
230 if ch == '-' || ch == '_' {
231 out.push(' ');
232 } else {
233 for u in ch.to_uppercase() {
234 out.push(ascii_fold_char(u));
235 }
236 }
237 }
238 out
239}
240
241pub(crate) fn ascii_fold(s: &str) -> String {
245 s.chars().map(ascii_fold_char).collect()
246}
247
248fn ascii_fold_char(c: char) -> char {
249 match c {
250 'À'..='Å' | 'à'..='å' => {
251 if c.is_ascii_uppercase() || c.is_uppercase() {
252 'A'
253 } else {
254 'a'
255 }
256 }
257 'Ç' => 'C',
258 'ç' => 'c',
259 'È'..='Ë' => 'E',
260 'è'..='ë' => 'e',
261 'Ì'..='Ï' => 'I',
262 'ì'..='ï' => 'i',
263 'Ñ' => 'N',
264 'ñ' => 'n',
265 'Ò'..='Ö' | 'Ø' => 'O',
266 'ò'..='ö' | 'ø' => 'o',
267 'Ù'..='Ü' => 'U',
268 'ù'..='ü' => 'u',
269 'Ý' | 'Ÿ' => 'Y',
270 'ý' | 'ÿ' => 'y',
271 'Š' => 'S',
272 'š' => 's',
273 'Ž' => 'Z',
274 'ž' => 'z',
275 _ => c,
276 }
277}
278
279#[cfg(test)]
280mod rank_tests {
281 use super::{looks_like_fk, rank_candidates};
282 use crate::data::LookupRow;
283
284 fn cand(value: &str, weight: u32, conf: f64) -> (LookupRow, f64) {
285 (
286 LookupRow {
287 pattern: "*".into(),
288 element: "Model".into(),
289 value: value.into(),
290 weight,
291 },
292 conf,
293 )
294 }
295
296 #[test]
297 fn fk_detection() {
298 assert!(looks_like_fk("29000005"));
299 assert!(!looks_like_fk("Model 3"));
300 assert!(!looks_like_fk("")); assert!(!looks_like_fk("A4"));
302 }
303
304 #[test]
307 fn resolved_name_beats_raw_fk_either_order() {
308 for mut g in [
309 [cand("29000005", 0, 1.0), cand("Model 3", 0, 1.0)],
310 [cand("Model 3", 0, 1.0), cand("29000005", 0, 1.0)],
311 ] {
312 g.sort_by(rank_candidates);
313 assert_eq!(g[0].0.value, "Model 3");
314 }
315 }
316
317 #[test]
318 fn weight_then_confidence_dominate() {
319 let mut g = [cand("Model 3", 0, 1.0), cand("29000005", 5, 0.1)];
320 g.sort_by(rank_candidates);
321 assert_eq!(g[0].0.value, "29000005", "a weighted row wins outright");
322
323 let mut g = [cand("low", 0, 0.2), cand("high", 0, 0.9)];
324 g.sort_by(rank_candidates);
325 assert_eq!(g[0].0.value, "high", "higher confidence breaks weight tie");
326 }
327
328 #[test]
329 fn fully_tied_is_order_independent() {
330 let mut a = [cand("Zeta", 0, 1.0), cand("Alpha", 0, 1.0)];
331 let mut b = [cand("Alpha", 0, 1.0), cand("Zeta", 0, 1.0)];
332 a.sort_by(rank_candidates);
333 b.sort_by(rank_candidates);
334 assert_eq!(a[0].0.value, "Alpha");
335 assert_eq!(a[0].0.value, b[0].0.value);
336 }
337}