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 map for raw dealer/registry strings that don't
260/// match the canonical name. Every target here must exist in `makes.fst` (i.e.
261/// be WMI-derived or listed in [`crate::build::EXTRA_MAKES`]).
262fn make_alias(upper: &str) -> Option<&'static str> {
263    Some(match upper {
264        "KG" | "KG MOBILITY" => "KGM",
265        "LINK" | "LYNK" | "LINK & CO" => "LYNK & CO",
266        "LEOPARD" | "BAO" => "FANGCHENGBAO",
267        "MHERO" | "MENGSHI" => "M-HERO",
268        "ZUNJIE" => "MAEXTRO",
269        "DFM" => "DONGFENG",
270        _ => return None,
271    })
272}
273
274/// Make-resolution core, parameterised over a membership test so it unit-tests
275/// without a real FST. See [`Catalog::resolve_make`].
276fn resolve_make_with(raw: &str, contains: impl Fn(&str) -> bool) -> Option<String> {
277    let up = raw.trim().to_ascii_uppercase();
278    if up.is_empty() {
279        return None;
280    }
281    let try_one = |s: &str| -> Option<String> {
282        if contains(s) {
283            return Some(s.to_string());
284        }
285        match make_alias(s) {
286            Some(canon) if contains(canon) => Some(canon.to_string()),
287            _ => None,
288        }
289    };
290    if let Some(hit) = try_one(&up) {
291        return Some(hit);
292    }
293    // Model glued on: drop trailing space-separated tokens, longest prefix first.
294    let toks: Vec<&str> = up.split_whitespace().collect();
295    for n in (1..toks.len()).rev() {
296        if let Some(hit) = try_one(&toks[..n].join(" ")) {
297            return Some(hit);
298        }
299    }
300    None
301}
302
303#[cfg(test)]
304mod resolve_tests {
305    use super::resolve_make_with;
306    use std::collections::HashSet;
307
308    fn fake(items: &[&str]) -> HashSet<String> {
309        items.iter().map(|s| s.to_string()).collect()
310    }
311
312    #[test]
313    fn resolve_make_exact_alias_and_glued() {
314        let makes = fake(&[
315            "KGM",
316            "LYNK & CO",
317            "FANGCHENGBAO",
318            "DONGFENG",
319            "M-HERO",
320            "LINKTOUR",
321        ]);
322        let c = |k: &str| makes.contains(k);
323        // exact, case-insensitive
324        assert_eq!(resolve_make_with("dongfeng", c).as_deref(), Some("DONGFENG"));
325        // aliases
326        assert_eq!(resolve_make_with("KG", c).as_deref(), Some("KGM"));
327        assert_eq!(resolve_make_with("KG Mobility", c).as_deref(), Some("KGM"));
328        assert_eq!(resolve_make_with("LINK", c).as_deref(), Some("LYNK & CO"));
329        assert_eq!(resolve_make_with("Leopard", c).as_deref(), Some("FANGCHENGBAO"));
330        assert_eq!(resolve_make_with("BAO", c).as_deref(), Some("FANGCHENGBAO"));
331        // model glued on
332        assert_eq!(
333            resolve_make_with("LEOPARD 5", c).as_deref(),
334            Some("FANGCHENGBAO")
335        );
336        assert_eq!(
337            resolve_make_with("LINKTOUR ALUMI-PLUS", c).as_deref(),
338            Some("LINKTOUR")
339        );
340        assert_eq!(resolve_make_with("M-HERO 917", c).as_deref(), Some("M-HERO"));
341        // no resolution
342        assert_eq!(resolve_make_with("WALDO", c), None);
343        assert_eq!(resolve_make_with("", c), None);
344    }
345}