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 vehicle
130 }
131
132 fn apply_wmi_rules(&self, vin: &Vin, vehicle: &mut Vehicle) {
136 let Some(wmi_rules) = &self.wmi_rules else {
137 return;
138 };
139 let Some(rules) = wmi_rules.get(vin.wmi()) else {
140 return;
141 };
142 let after_wmi = &vin.as_str()[3..];
143 for rule in rules {
144 if !after_wmi.starts_with(&rule.remainder) {
145 continue;
146 }
147 if !rule.make.is_empty() {
148 vehicle.make = Some(rule.make.clone());
149 }
150 if !rule.model.is_empty() {
151 vehicle.model = Some(rule.model.clone());
152 }
153 return;
154 }
155 }
156
157 fn fill_pattern_attrs(&self, vin: &Vin, vehicle: &mut Vehicle) {
158 let Some(schemas) = self.wmi_schema.get(vin.wmi()) else {
159 return;
160 };
161 let mut groups: HashMap<Element, Vec<(LookupRow, f64)>> = HashMap::new();
162 for sch in &schemas {
163 let Some(lookups) = self.schema_lookup.get(&sch.id) else {
164 continue;
165 };
166 for lk in lookups {
167 let Some(elem) = Element::from_str(&lk.element) else {
168 continue;
169 };
170 let conf = pattern::confidence(&lk.pattern, vin.vds(), vin.vis());
171 if conf > elem.confidence_cutoff() {
172 groups.entry(elem).or_default().push((lk, conf));
173 }
174 }
175 }
176 for (elem, mut group) in groups {
177 group.sort_by(rank_candidates);
178 if let Some((top, _)) = group.into_iter().next() {
179 elem.apply(vehicle, top.value);
180 }
181 }
182 }
183}
184
185fn rank_candidates(a: &(LookupRow, f64), b: &(LookupRow, f64)) -> std::cmp::Ordering {
197 use std::cmp::Ordering;
198 b.0.weight
199 .cmp(&a.0.weight)
200 .then(b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal))
201 .then_with(|| looks_like_fk(&a.0.value).cmp(&looks_like_fk(&b.0.value)))
202 .then_with(|| a.0.value.cmp(&b.0.value))
203}
204
205fn looks_like_fk(value: &str) -> bool {
208 !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit())
209}
210
211pub(crate) fn normalize_make(s: &str) -> String {
216 let mut out = String::with_capacity(s.len());
217 for ch in s.chars() {
218 if ch == '-' || ch == '_' {
219 out.push(' ');
220 } else {
221 for u in ch.to_uppercase() {
222 out.push(ascii_fold_char(u));
223 }
224 }
225 }
226 out
227}
228
229pub(crate) fn ascii_fold(s: &str) -> String {
233 s.chars().map(ascii_fold_char).collect()
234}
235
236fn ascii_fold_char(c: char) -> char {
237 match c {
238 'À'..='Å' | 'à'..='å' => {
239 if c.is_ascii_uppercase() || c.is_uppercase() {
240 'A'
241 } else {
242 'a'
243 }
244 }
245 'Ç' => 'C',
246 'ç' => 'c',
247 'È'..='Ë' => 'E',
248 'è'..='ë' => 'e',
249 'Ì'..='Ï' => 'I',
250 'ì'..='ï' => 'i',
251 'Ñ' => 'N',
252 'ñ' => 'n',
253 'Ò'..='Ö' | 'Ø' => 'O',
254 'ò'..='ö' | 'ø' => 'o',
255 'Ù'..='Ü' => 'U',
256 'ù'..='ü' => 'u',
257 'Ý' | 'Ÿ' => 'Y',
258 'ý' | 'ÿ' => 'y',
259 'Š' => 'S',
260 'š' => 's',
261 'Ž' => 'Z',
262 'ž' => 'z',
263 _ => c,
264 }
265}
266
267#[cfg(test)]
268mod rank_tests {
269 use super::{looks_like_fk, rank_candidates};
270 use crate::data::LookupRow;
271
272 fn cand(value: &str, weight: u32, conf: f64) -> (LookupRow, f64) {
273 (
274 LookupRow {
275 pattern: "*".into(),
276 element: "Model".into(),
277 value: value.into(),
278 weight,
279 },
280 conf,
281 )
282 }
283
284 #[test]
285 fn fk_detection() {
286 assert!(looks_like_fk("29000005"));
287 assert!(!looks_like_fk("Model 3"));
288 assert!(!looks_like_fk("")); assert!(!looks_like_fk("A4"));
290 }
291
292 #[test]
295 fn resolved_name_beats_raw_fk_either_order() {
296 for mut g in [
297 [cand("29000005", 0, 1.0), cand("Model 3", 0, 1.0)],
298 [cand("Model 3", 0, 1.0), cand("29000005", 0, 1.0)],
299 ] {
300 g.sort_by(rank_candidates);
301 assert_eq!(g[0].0.value, "Model 3");
302 }
303 }
304
305 #[test]
306 fn weight_then_confidence_dominate() {
307 let mut g = [cand("Model 3", 0, 1.0), cand("29000005", 5, 0.1)];
308 g.sort_by(rank_candidates);
309 assert_eq!(g[0].0.value, "29000005", "a weighted row wins outright");
310
311 let mut g = [cand("low", 0, 0.2), cand("high", 0, 0.9)];
312 g.sort_by(rank_candidates);
313 assert_eq!(g[0].0.value, "high", "higher confidence breaks weight tie");
314 }
315
316 #[test]
317 fn fully_tied_is_order_independent() {
318 let mut a = [cand("Zeta", 0, 1.0), cand("Alpha", 0, 1.0)];
319 let mut b = [cand("Alpha", 0, 1.0), cand("Zeta", 0, 1.0)];
320 a.sort_by(rank_candidates);
321 b.sort_by(rank_candidates);
322 assert_eq!(a[0].0.value, "Alpha");
323 assert_eq!(a[0].0.value, b[0].0.value);
324 }
325}