Skip to main content

vin_decode/
catalog.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use crate::data::{BodyRow, EngineRow, EuModelRow, ModelRow};
6use crate::maps::{FstMap, FstSet, data_dir};
7use crate::types::{BodyType, DriveType, FuelType, Transmission};
8
9/// Read-only catalog of every make and model present in the lookup data.
10///
11/// Useful for populating dropdowns, validators, or schema-typed enum columns
12/// in webapps. Combines the vPIC pattern-derived index (`make_models`) with the
13/// EU/global rip catalog (`eu_brand_models`, `eu_engines`) for non-US coverage.
14pub struct Catalog {
15    makes: FstSet,
16    make_models: FstMap<ModelRow>,
17    eu_brand_models: Option<FstMap<EuModelRow>>,
18    eu_engines: Option<FstMap<EngineRow>>,
19    eu_body: Option<FstMap<BodyRow>>,
20    /// Lazily-built model → makes reverse index, cached for the Catalog's life.
21    model_to_makes: OnceLock<HashMap<String, Vec<String>>>,
22}
23
24impl Catalog {
25    /// Open the catalog using the default data directory (see [`crate::Decoder::new`]).
26    pub fn new() -> crate::Result<Self> {
27        let dir = data_dir();
28        #[cfg(feature = "embedded")]
29        crate::embedded::ensure_installed(&dir).ok();
30        Self::open(&dir)
31    }
32
33    /// Open the catalog against an explicit data directory. EU rip tables are
34    /// optional — they're only loaded if the corresponding `.fst` files exist.
35    pub fn open(dir: &Path) -> crate::Result<Self> {
36        let eu_brand_models = if dir.join("eu_brand_models.fst").exists() {
37            Some(FstMap::open(dir)?)
38        } else {
39            None
40        };
41        let eu_engines = if dir.join("eu_engines.fst").exists() {
42            Some(FstMap::open(dir)?)
43        } else {
44            None
45        };
46        let eu_body = if dir.join("eu_body.fst").exists() {
47            Some(FstMap::open(dir)?)
48        } else {
49            None
50        };
51        Ok(Catalog {
52            makes: FstSet::open(&dir.join("makes.fst"))?,
53            make_models: FstMap::open(dir)?,
54            eu_brand_models,
55            eu_engines,
56            eu_body,
57            model_to_makes: OnceLock::new(),
58        })
59    }
60
61    /// Sorted list of every make name (uppercase, deduped).
62    pub fn all_makes(&self) -> Vec<String> {
63        self.makes.keys()
64    }
65
66    /// Case-insensitive membership check for a make name.
67    pub fn has_make(&self, make: &str) -> bool {
68        self.makes.contains(&make.to_ascii_uppercase())
69    }
70
71    /// Resolve a raw make string — dealer / listing form, often uppercased and
72    /// sometimes with the model glued on — to a canonical make present in the
73    /// catalog, or `None` if it doesn't resolve.
74    ///
75    /// Tries, in order: exact membership, a curated alias table (e.g. `"LINK"`/
76    /// `"LYNK"` → `"LYNK & CO"`, `"LEOPARD"`/`"BAO"` → `"FANGCHENGBAO"`, `"KG"`
77    /// → `"KGM"`), then dropping trailing whitespace-separated tokens so a glued
78    /// model falls off (`"LEOPARD 5"` → `"FANGCHENGBAO"`, `"LINKTOUR ALUMI"` →
79    /// `"LINKTOUR"`). This is what a downstream scraper should call to canonicalise
80    /// a listing's make before accepting it.
81    pub fn resolve_make(&self, raw: &str) -> Option<String> {
82        resolve_make_with(raw, |k| self.makes.contains(k))
83    }
84
85    /// Total number of distinct makes.
86    pub fn make_count(&self) -> u64 {
87        self.makes.len()
88    }
89
90    /// Sorted list of model names known for the given make (case-insensitive lookup).
91    ///
92    /// Merges results from the vPIC pattern-derived index and the EU/global rip
93    /// catalog, deduped and sorted.
94    pub fn models_for_make(&self, make: &str) -> Vec<String> {
95        let key = crate::decoder::normalize_make(make);
96        let mut models: Vec<String> = self
97            .make_models
98            .get(&key)
99            .map(|rows| rows.into_iter().map(|r| r.name).collect())
100            .unwrap_or_default();
101        if let Some(eu) = &self.eu_brand_models {
102            if let Some(rows) = eu.get(&key) {
103                for r in rows {
104                    models.push(r.name);
105                }
106            }
107        }
108        models.sort();
109        models.dedup();
110        models
111    }
112
113    /// Reverse lookup: every make (uppercase) that lists `model`, matched
114    /// case-insensitively. Merges the vPIC and EU/global indices.
115    ///
116    /// The inverse index is built once on first call and cached for the
117    /// Catalog's lifetime, so consumers don't rebuild a model → make map per
118    /// boot. Returns an empty vec for an unknown model.
119    pub fn make_for_model(&self, model: &str) -> Vec<String> {
120        self.model_to_makes
121            .get_or_init(|| self.build_model_index())
122            .get(&model.to_ascii_uppercase())
123            .cloned()
124            .unwrap_or_default()
125    }
126
127    fn build_model_index(&self) -> HashMap<String, Vec<String>> {
128        let mut idx: HashMap<String, Vec<String>> = HashMap::new();
129        for make in self.make_models.keys() {
130            if let Some(rows) = self.make_models.get(&make) {
131                for r in rows {
132                    idx.entry(r.name.to_ascii_uppercase())
133                        .or_default()
134                        .push(make.clone());
135                }
136            }
137        }
138        if let Some(eu) = &self.eu_brand_models {
139            for make in eu.keys() {
140                if let Some(rows) = eu.get(&make) {
141                    for r in rows {
142                        idx.entry(r.name.to_ascii_uppercase())
143                            .or_default()
144                            .push(make.clone());
145                    }
146                }
147            }
148        }
149        for makes in idx.values_mut() {
150            makes.sort();
151            makes.dedup();
152        }
153        idx
154    }
155
156    /// EU/global rip listing of models for the given brand, with year ranges.
157    /// Returns an empty vec when the brand is unknown or the EU catalog is not
158    /// embedded.
159    pub fn eu_models_for(&self, brand: &str) -> Vec<EuModelRow> {
160        let key = crate::decoder::normalize_make(brand);
161        self.eu_brand_models
162            .as_ref()
163            .and_then(|m| m.get(&key))
164            .unwrap_or_default()
165    }
166
167    /// Engine variants known for the given brand. Filter by `model` in user
168    /// code, or use [`Catalog::engines_for`].
169    pub fn engines_for_brand(&self, brand: &str) -> Vec<EngineRow> {
170        let key = crate::decoder::normalize_make(brand);
171        self.eu_engines
172            .as_ref()
173            .and_then(|m| m.get(&key))
174            .unwrap_or_default()
175    }
176
177    /// Engine variants known for `(brand, model)`. Both args are matched
178    /// case-insensitively against the canonical uppercase keys.
179    pub fn engines_for(&self, brand: &str, model: &str) -> Vec<EngineRow> {
180        let model_key = model.to_ascii_uppercase();
181        self.engines_for_brand(brand)
182            .into_iter()
183            .filter(|r| r.model == model_key)
184            .collect()
185    }
186
187    /// EU type-approval bodywork types known for `(make, model)`.
188    ///
189    /// `make` is normalised the same way as [`Catalog::eu_models_for`] /
190    /// [`Catalog::models_for_make`]; `model` is matched case-insensitively
191    /// against the canonical uppercase model key. Returns an empty vec when the
192    /// pair is unknown or the optional `eu_body` map is not embedded.
193    pub fn body_for(&self, make: &str, model: &str) -> Vec<BodyType> {
194        let key = crate::decoder::normalize_make(make);
195        let model_key = model.to_ascii_uppercase();
196        self.eu_body
197            .as_ref()
198            .and_then(|m| m.get(&key))
199            .unwrap_or_default()
200            .into_iter()
201            .find(|r| r.model == model_key)
202            .map(|r| r.bodies)
203            .unwrap_or_default()
204    }
205
206    /// Static list of every standard [`BodyType`] variant (EU type-approval
207    /// bodywork codes), useful for typed dropdowns. Excludes
208    /// [`BodyType::Unknown`], which is the absence of a code, not a type.
209    pub fn body_types() -> &'static [BodyType] {
210        crate::types::BODY_TYPES
211    }
212
213    /// Static list of every [`FuelType`] variant — useful for typed dropdowns.
214    pub fn fuel_types() -> &'static [FuelType] {
215        &[
216            FuelType::Gasoline,
217            FuelType::Diesel,
218            FuelType::Electric,
219            FuelType::Hybrid,
220            FuelType::PluginHybrid,
221            FuelType::Ethanol,
222            FuelType::FlexFuel,
223            FuelType::Cng,
224            FuelType::Lng,
225            FuelType::Lpg,
226            FuelType::Hydrogen,
227            FuelType::FuelCell,
228            FuelType::Methanol,
229            FuelType::NaturalGas,
230            FuelType::Other,
231        ]
232    }
233
234    /// Static list of every [`DriveType`] variant — useful for typed dropdowns.
235    pub fn drive_types() -> &'static [DriveType] {
236        &[
237            DriveType::Fwd,
238            DriveType::Rwd,
239            DriveType::Awd,
240            DriveType::FourWheelDrive,
241            DriveType::Other,
242        ]
243    }
244
245    /// Static list of every [`Transmission`] variant — useful for typed dropdowns.
246    pub fn transmissions() -> &'static [Transmission] {
247        &[
248            Transmission::Manual,
249            Transmission::Automatic,
250            Transmission::DualClutch,
251            Transmission::Cvt,
252            Transmission::SingleSpeed,
253            Transmission::AutomatedManual,
254            Transmission::Other,
255        ]
256    }
257}
258
259/// Curated alias → canonical make table for raw dealer/registry strings that
260/// don't match a catalog make. One source of truth for two layers:
261///  - runtime: [`make_alias`] redirects a raw string to its canonical make.
262///  - build: [`crate::build::merge_makes`] drops every alias *source* from
263///    `makes.fst` (so [`Catalog::resolve_make`] can't short-circuit on a
264///    zero-model twin like `"DS"` / `"MERCEDES-AMG"` / `"ROLLS ROYCE"`) and
265///    force-adds every *target*.
266///
267/// Every target must be a real catalog make (WMI/rip-derived or in
268/// [`crate::build::EXTRA_MAKES`]). Sources that aren't catalog makes (raw dealer
269/// tokens like `"KG"` / `"LEOPARD"`) are simply never present to drop — harmless.
270pub(crate) const MAKE_ALIASES: &[(&str, &str)] = &[
271    ("KG", "KGM"),
272    ("KG MOBILITY", "KGM"),
273    ("LINK", "LYNK & CO"),
274    ("LYNK", "LYNK & CO"),
275    ("LINK & CO", "LYNK & CO"),
276    ("LEOPARD", "FANGCHENGBAO"),
277    ("BAO", "FANGCHENGBAO"),
278    ("MHERO", "M-HERO"),
279    ("MENGSHI", "M-HERO"),
280    ("ZUNJIE", "MAEXTRO"),
281    ("DFM", "DONGFENG"),
282    // zero-model duplicate makes → the populated canonical sibling. These
283    // sources ARE catalog makes, so merge_makes drops them from makes.fst.
284    ("DS", "DS AUTOMOBILES"),
285    ("MERCEDES-AMG", "MERCEDES-BENZ"),
286    ("ROLLS ROYCE", "ROLLS-ROYCE"),
287];
288
289/// Look up a raw uppercase make string in the curated [`MAKE_ALIASES`] table.
290fn make_alias(upper: &str) -> Option<&'static str> {
291    MAKE_ALIASES
292        .iter()
293        .find(|(from, _)| *from == upper)
294        .map(|(_, to)| *to)
295}
296
297/// Make-resolution core, parameterised over a membership test so it unit-tests
298/// without a real FST. See [`Catalog::resolve_make`].
299fn resolve_make_with(raw: &str, contains: impl Fn(&str) -> bool) -> Option<String> {
300    let up = raw.trim().to_ascii_uppercase();
301    if up.is_empty() {
302        return None;
303    }
304    let try_one = |s: &str| -> Option<String> {
305        if contains(s) {
306            return Some(s.to_string());
307        }
308        match make_alias(s) {
309            Some(canon) if contains(canon) => Some(canon.to_string()),
310            _ => None,
311        }
312    };
313    if let Some(hit) = try_one(&up) {
314        return Some(hit);
315    }
316    // Model glued on: drop trailing space-separated tokens, longest prefix first.
317    let toks: Vec<&str> = up.split_whitespace().collect();
318    for n in (1..toks.len()).rev() {
319        if let Some(hit) = try_one(&toks[..n].join(" ")) {
320            return Some(hit);
321        }
322    }
323    None
324}
325
326#[cfg(test)]
327mod resolve_tests {
328    use super::resolve_make_with;
329    use std::collections::HashSet;
330
331    fn fake(items: &[&str]) -> HashSet<String> {
332        items.iter().map(|s| s.to_string()).collect()
333    }
334
335    #[test]
336    fn resolve_make_exact_alias_and_glued() {
337        let makes = fake(&[
338            "KGM",
339            "LYNK & CO",
340            "FANGCHENGBAO",
341            "DONGFENG",
342            "M-HERO",
343            "LINKTOUR",
344        ]);
345        let c = |k: &str| makes.contains(k);
346        // exact, case-insensitive
347        assert_eq!(resolve_make_with("dongfeng", c).as_deref(), Some("DONGFENG"));
348        // aliases
349        assert_eq!(resolve_make_with("KG", c).as_deref(), Some("KGM"));
350        assert_eq!(resolve_make_with("KG Mobility", c).as_deref(), Some("KGM"));
351        assert_eq!(resolve_make_with("LINK", c).as_deref(), Some("LYNK & CO"));
352        assert_eq!(resolve_make_with("Leopard", c).as_deref(), Some("FANGCHENGBAO"));
353        assert_eq!(resolve_make_with("BAO", c).as_deref(), Some("FANGCHENGBAO"));
354        // model glued on
355        assert_eq!(
356            resolve_make_with("LEOPARD 5", c).as_deref(),
357            Some("FANGCHENGBAO")
358        );
359        assert_eq!(
360            resolve_make_with("LINKTOUR ALUMI-PLUS", c).as_deref(),
361            Some("LINKTOUR")
362        );
363        assert_eq!(resolve_make_with("M-HERO 917", c).as_deref(), Some("M-HERO"));
364        // no resolution
365        assert_eq!(resolve_make_with("WALDO", c), None);
366        assert_eq!(resolve_make_with("", c), None);
367    }
368}