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    /// Total number of distinct makes.
72    pub fn make_count(&self) -> u64 {
73        self.makes.len()
74    }
75
76    /// Sorted list of model names known for the given make (case-insensitive lookup).
77    ///
78    /// Merges results from the vPIC pattern-derived index and the EU/global rip
79    /// catalog, deduped and sorted.
80    pub fn models_for_make(&self, make: &str) -> Vec<String> {
81        let key = crate::decoder::normalize_make(make);
82        let mut models: Vec<String> = self
83            .make_models
84            .get(&key)
85            .map(|rows| rows.into_iter().map(|r| r.name).collect())
86            .unwrap_or_default();
87        if let Some(eu) = &self.eu_brand_models {
88            if let Some(rows) = eu.get(&key) {
89                for r in rows {
90                    models.push(r.name);
91                }
92            }
93        }
94        models.sort();
95        models.dedup();
96        models
97    }
98
99    /// Reverse lookup: every make (uppercase) that lists `model`, matched
100    /// case-insensitively. Merges the vPIC and EU/global indices.
101    ///
102    /// The inverse index is built once on first call and cached for the
103    /// Catalog's lifetime, so consumers don't rebuild a model → make map per
104    /// boot. Returns an empty vec for an unknown model.
105    pub fn make_for_model(&self, model: &str) -> Vec<String> {
106        self.model_to_makes
107            .get_or_init(|| self.build_model_index())
108            .get(&model.to_ascii_uppercase())
109            .cloned()
110            .unwrap_or_default()
111    }
112
113    fn build_model_index(&self) -> HashMap<String, Vec<String>> {
114        let mut idx: HashMap<String, Vec<String>> = HashMap::new();
115        for make in self.make_models.keys() {
116            if let Some(rows) = self.make_models.get(&make) {
117                for r in rows {
118                    idx.entry(r.name.to_ascii_uppercase())
119                        .or_default()
120                        .push(make.clone());
121                }
122            }
123        }
124        if let Some(eu) = &self.eu_brand_models {
125            for make in eu.keys() {
126                if let Some(rows) = eu.get(&make) {
127                    for r in rows {
128                        idx.entry(r.name.to_ascii_uppercase())
129                            .or_default()
130                            .push(make.clone());
131                    }
132                }
133            }
134        }
135        for makes in idx.values_mut() {
136            makes.sort();
137            makes.dedup();
138        }
139        idx
140    }
141
142    /// EU/global rip listing of models for the given brand, with year ranges.
143    /// Returns an empty vec when the brand is unknown or the EU catalog is not
144    /// embedded.
145    pub fn eu_models_for(&self, brand: &str) -> Vec<EuModelRow> {
146        let key = crate::decoder::normalize_make(brand);
147        self.eu_brand_models
148            .as_ref()
149            .and_then(|m| m.get(&key))
150            .unwrap_or_default()
151    }
152
153    /// Engine variants known for the given brand. Filter by `model` in user
154    /// code, or use [`Catalog::engines_for`].
155    pub fn engines_for_brand(&self, brand: &str) -> Vec<EngineRow> {
156        let key = crate::decoder::normalize_make(brand);
157        self.eu_engines
158            .as_ref()
159            .and_then(|m| m.get(&key))
160            .unwrap_or_default()
161    }
162
163    /// Engine variants known for `(brand, model)`. Both args are matched
164    /// case-insensitively against the canonical uppercase keys.
165    pub fn engines_for(&self, brand: &str, model: &str) -> Vec<EngineRow> {
166        let model_key = model.to_ascii_uppercase();
167        self.engines_for_brand(brand)
168            .into_iter()
169            .filter(|r| r.model == model_key)
170            .collect()
171    }
172
173    /// EU type-approval bodywork types known for `(make, model)`.
174    ///
175    /// `make` is normalised the same way as [`Catalog::eu_models_for`] /
176    /// [`Catalog::models_for_make`]; `model` is matched case-insensitively
177    /// against the canonical uppercase model key. Returns an empty vec when the
178    /// pair is unknown or the optional `eu_body` map is not embedded.
179    pub fn body_for(&self, make: &str, model: &str) -> Vec<BodyType> {
180        let key = crate::decoder::normalize_make(make);
181        let model_key = model.to_ascii_uppercase();
182        self.eu_body
183            .as_ref()
184            .and_then(|m| m.get(&key))
185            .unwrap_or_default()
186            .into_iter()
187            .find(|r| r.model == model_key)
188            .map(|r| r.bodies)
189            .unwrap_or_default()
190    }
191
192    /// Static list of every standard [`BodyType`] variant (EU type-approval
193    /// bodywork codes), useful for typed dropdowns. Excludes
194    /// [`BodyType::Unknown`], which is the absence of a code, not a type.
195    pub fn body_types() -> &'static [BodyType] {
196        crate::types::BODY_TYPES
197    }
198
199    /// Static list of every [`FuelType`] variant — useful for typed dropdowns.
200    pub fn fuel_types() -> &'static [FuelType] {
201        &[
202            FuelType::Gasoline,
203            FuelType::Diesel,
204            FuelType::Electric,
205            FuelType::Hybrid,
206            FuelType::PluginHybrid,
207            FuelType::Ethanol,
208            FuelType::FlexFuel,
209            FuelType::Cng,
210            FuelType::Lng,
211            FuelType::Lpg,
212            FuelType::Hydrogen,
213            FuelType::FuelCell,
214            FuelType::Methanol,
215            FuelType::NaturalGas,
216            FuelType::Other,
217        ]
218    }
219
220    /// Static list of every [`DriveType`] variant — useful for typed dropdowns.
221    pub fn drive_types() -> &'static [DriveType] {
222        &[
223            DriveType::Fwd,
224            DriveType::Rwd,
225            DriveType::Awd,
226            DriveType::FourWheelDrive,
227            DriveType::Other,
228        ]
229    }
230
231    /// Static list of every [`Transmission`] variant — useful for typed dropdowns.
232    pub fn transmissions() -> &'static [Transmission] {
233        &[
234            Transmission::Manual,
235            Transmission::Automatic,
236            Transmission::DualClutch,
237            Transmission::Cvt,
238            Transmission::SingleSpeed,
239            Transmission::AutomatedManual,
240            Transmission::Other,
241        ]
242    }
243}