Skip to main content

fastsim_schema/v1/
mod.rs

1//! Database organizational schema version 1 for the `fastsim-vehicles` repository.
2//!
3//! Provides parsing, validation, and serialization of vehicle identification paths
4//! (e.g., `v1/fastsim-3/conv/ford/fusion/2012/base/r1`) along with error handling
5//! and index entry structures for vehicle discovery.
6
7use super::*;
8
9mod error;
10mod index;
11mod search;
12
13#[cfg(feature = "wasm")]
14mod wasm;
15
16pub use error::VehicleSchemaV1Error;
17pub use index::{read_jsonl_v1, write_jsonl_v1, IndexEntryV1};
18pub use search::{search_v1, QueryV1};
19
20/// Database organizational schema version 1 for the `fastsim-vehicles` repository.
21///
22/// Serializes to/from an 8-segment path-segment string, e.g. `"v1/fastsim-3/conv/ford/fusion/2012/base/r1"`
23///
24/// Segments in order:
25/// 1. `v1` — schema version marker
26/// 2. `fastsim-{N}` — FASTSim version
27/// 3. `{powertrain}` — powertrain type (e.g., "conv", "hev", "phev", "bev")
28/// 4. `{make}` — vehicle make
29/// 5. `{model}` — vehicle model (may include trim information)
30/// 6. `{year}` — model year or year range
31/// 7. `{variant}` — variant/feature configuration (e.g., "base")
32/// 8. `r{N}` — model revision/version for corrections
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(into = "String", try_from = "String")]
35pub struct VehicleSchemaV1 {
36    /// FASTSim version
37    pub fastsim_version: u32,
38    /// Powertrain type (e.g., "conv", "hev", "phev", "bev")
39    pub powertrain: String,
40    /// Vehicle make
41    pub make: String,
42    /// Vehicle model (may include trim information)
43    pub model: String,
44    /// Vehicle model year (or range of years)
45    pub year: String,
46    /// Vehicle variant (describes what modeling features are active, etc.)
47    pub variant: String,
48    /// Model revision/version for correcting model-level issues over time
49    pub revision: u32,
50}
51
52impl std::fmt::Display for VehicleSchemaV1 {
53    /// Display the schema as its path-segment string representation.
54    ///
55    /// Formats as: `v1/fastsim-{N}/{powertrain}/{make}/{model}/{year}/{variant}/r{N}`
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}", self.path_segments().join("/"))
58    }
59}
60
61impl From<VehicleSchemaV1> for String {
62    fn from(s: VehicleSchemaV1) -> Self {
63        s.to_string()
64    }
65}
66
67impl std::str::FromStr for VehicleSchemaV1 {
68    type Err = VehicleSchemaV1Error;
69    /// Parse a schema from its path-segment string representation.
70    ///
71    /// Expected format (8 segments):
72    /// `v1/fastsim-{N}/{powertrain}/{make}/{model}/{year}/{variant}/r{N}`
73    ///
74    /// # Errors
75    /// This parser has two validation phases and may fail in either phase:
76    /// - Structural parsing (`parse_structural`): wrong segment count, wrong required
77    ///   prefixes, or non-numeric version fields.
78    /// - Canonical identifier validation (`new`): any of `powertrain`, `make`, `model`,
79    ///   `year`, or `variant` is not already a canonical identifier.
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        let raw = Self::parse_structural(s)?;
82        Self::new(
83            raw.fastsim_version,
84            raw.powertrain,
85            raw.make,
86            raw.model,
87            raw.year,
88            raw.variant,
89            raw.revision,
90        )
91    }
92}
93
94impl TryFrom<String> for VehicleSchemaV1 {
95    type Error = VehicleSchemaV1Error;
96    fn try_from(s: String) -> Result<Self, Self::Error> {
97        s.parse()
98    }
99}
100
101impl VehicleSchemaV1 {
102    /// Parse a v1 schema path structurally, without canonical identifier validation.
103    ///
104    /// This only validates path shape and numeric fields:
105    /// - 8 segments
106    /// - `v1` schema prefix
107    /// - `fastsim-{N}` and `r{N}` numeric segments
108    ///
109    /// It intentionally does not enforce canonical formatting for `powertrain`,
110    /// `make`, `model`, `year`, or `variant`. Call `new` (or `from_str`) for full
111    /// canonical validation.
112    pub(crate) fn parse_structural(s: &str) -> Result<Self, VehicleSchemaV1Error> {
113        let parts: Vec<&str> = s.split('/').collect();
114        if parts.len() != 8 {
115            return Err(VehicleSchemaV1Error::SegmentCount {
116                input: s.to_string(),
117                actual: parts.len(),
118            });
119        }
120        if parts[0] != "v1" {
121            return Err(VehicleSchemaV1Error::WrongSchemaPrefix {
122                found: parts[0].to_string(),
123            });
124        }
125        let fastsim_version = parts[1]
126            .strip_prefix("fastsim-")
127            .ok_or_else(|| VehicleSchemaV1Error::MissingFastsimVersionPrefix {
128                segment: parts[1].to_string(),
129            })?
130            .parse::<u32>()
131            .map_err(|source| VehicleSchemaV1Error::InvalidFastsimVersion {
132                segment: parts[1].to_string(),
133                source,
134            })?;
135        let revision = parts[7]
136            .strip_prefix('r')
137            .ok_or_else(|| VehicleSchemaV1Error::MissingRevisionPrefix {
138                segment: parts[7].to_string(),
139            })?
140            .parse::<u32>()
141            .map_err(|source| VehicleSchemaV1Error::InvalidRevision {
142                segment: parts[7].to_string(),
143                source,
144            })?;
145
146        Ok(Self {
147            fastsim_version,
148            powertrain: parts[2].to_string(),
149            make: parts[3].to_string(),
150            model: parts[4].to_string(),
151            year: parts[5].to_string(),
152            variant: parts[6].to_string(),
153            revision,
154        })
155    }
156
157    /// Construct a schema from parsed field values, enforcing canonical identifiers.
158    ///
159    /// # Errors
160    /// Returns an error if any of `powertrain`, `make`, `model`, `year`, or `variant`
161    /// is not already in canonical identifier form (lowercase, ASCII, dashes and periods allowed).
162    pub fn new(
163        fastsim_version: u32,
164        powertrain: String,
165        make: String,
166        model: String,
167        year: String,
168        variant: String,
169        revision: u32,
170    ) -> Result<Self, VehicleSchemaV1Error> {
171        for (field, segment) in [
172            ("powertrain", &powertrain),
173            ("make", &make),
174            ("model", &model),
175            ("year", &year),
176            ("variant", &variant),
177        ] {
178            if !Self::validate_identifier(segment) {
179                return Err(VehicleSchemaV1Error::InvalidIdentifier {
180                    field,
181                    value: segment.to_string(),
182                    suggestion: Self::normalize_identifier(segment),
183                });
184            }
185        }
186        Ok(Self {
187            fastsim_version,
188            powertrain,
189            make,
190            model,
191            year,
192            variant,
193            revision,
194        })
195    }
196
197    /// Returns `false` if `c` is not in:
198    /// - `a-z`
199    /// - `0-9`
200    /// - `-`
201    /// - `.`
202    fn allowed_character(c: char) -> bool {
203        c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'
204    }
205
206    /// Convert arbitrary text into the canonical identifier format used by schema paths.
207    /// Examples:
208    /// - `"Outback XT"` → `"outback-xt"`
209    /// - `"Model_3"` → `"model-3"`.
210    pub fn normalize_identifier(s: &str) -> String {
211        let mut result = String::new();
212        let mut previous_was_dash = false;
213        let mut previous_was_dot = false;
214
215        for c in s.to_ascii_lowercase().chars() {
216            let c = match c {
217                c if Self::allowed_character(c) => c,
218                _ => '-',
219            };
220
221            // Collapse repeated separators of the same type
222            if c == '-' {
223                if previous_was_dash {
224                    continue;
225                }
226                previous_was_dash = true;
227                previous_was_dot = false;
228            } else if c == '.' {
229                if previous_was_dot {
230                    continue;
231                }
232                previous_was_dot = true;
233                previous_was_dash = false;
234            } else {
235                previous_was_dash = false;
236                previous_was_dot = false;
237            }
238            result.push(c);
239        }
240
241        result.trim_matches(|c| c == '-' || c == '.').to_string()
242    }
243
244    /// Validate that a string is a valid identifier for path segments.
245    pub fn validate_identifier(s: &str) -> bool {
246        !s.is_empty() && Self::normalize_identifier(s) == s
247    }
248
249    /// Suggests a normalized path when `raw` is structurally valid but not canonical.
250    ///
251    /// Returns `(current, suggested)` when a suggestion differs from the current path.
252    pub fn suggest_normalized_path(raw: &str) -> Option<(String, String)> {
253        let current = Self::parse_structural(raw).ok()?;
254
255        let suggested = Self {
256            fastsim_version: current.fastsim_version,
257            powertrain: Self::normalize_identifier(&current.powertrain),
258            make: Self::normalize_identifier(&current.make),
259            model: Self::normalize_identifier(&current.model),
260            year: Self::normalize_identifier(&current.year),
261            variant: Self::normalize_identifier(&current.variant),
262            revision: current.revision,
263        };
264
265        let current_path = current.to_string();
266        let suggested_path = suggested.to_string();
267        if current_path != suggested_path {
268            Some((current_path, suggested_path))
269        } else {
270            None
271        }
272    }
273
274    /// Build the ordered path segments as an 8-element array (without file extension).
275    ///
276    /// Returns in order:
277    /// 1. "v1" — schema version marker
278    /// 2. "fastsim-{N}" — FASTSim version
279    /// 3. powertrain — e.g., "conv", "hev", "phev", "bev"
280    /// 4. make — e.g., "ford", "tesla"
281    /// 5. model — e.g., "fusion", "model-3"
282    /// 6. year — e.g., "2012", "2020"
283    /// 7. variant — e.g., "base", "trim-package"
284    /// 8. "r{N}" — revision/version number
285    pub fn path_segments(&self) -> [String; 8] {
286        [
287            "v1".to_string(),
288            format!("fastsim-{}", self.fastsim_version),
289            self.powertrain.clone(),
290            self.make.clone(),
291            self.model.clone(),
292            self.year.clone(),
293            self.variant.clone(),
294            format!("r{}", self.revision),
295        ]
296    }
297
298    /// Build a local file path by joining the schema's path-segment string with a base directory and extension.
299    ///
300    /// Example: `base_dir/v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml`
301    pub fn build_filepath<P: AsRef<std::path::Path>>(
302        &self,
303        base_dir: P,
304        extension: &str,
305    ) -> std::path::PathBuf {
306        base_dir.as_ref().join(format!("{}.{}", self, extension))
307    }
308
309    /// Build a remote URL by joining the schema's path-segment string with a base URL and extension.
310    ///
311    /// If `base_url` is None, uses the default GitHub raw content URL.
312    /// Example: `https://raw.githubusercontent.com/.../v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml`
313    pub fn build_url(&self, base_url: Option<&str>, extension: &str) -> String {
314        format!(
315            "{}/{}.{}",
316            base_url
317                .map(|s| s.trim_end_matches('/'))
318                .unwrap_or(DEFAULT_DB_URL),
319            self,
320            extension
321        )
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn sample_schema() -> VehicleSchemaV1 {
330        VehicleSchemaV1::new(
331            3,
332            "conv".to_string(),
333            "ford".to_string(),
334            "fusion".to_string(),
335            "2012".to_string(),
336            "base".to_string(),
337            1,
338        )
339        .unwrap()
340    }
341
342    #[test]
343    fn test_serde_round_trip() {
344        let schema = sample_schema();
345        let serialized = serde_json::to_string(&schema).unwrap();
346        assert_eq!(serialized, "\"v1/fastsim-3/conv/ford/fusion/2012/base/r1\"");
347        let deserialized: VehicleSchemaV1 = serde_json::from_str(&serialized).unwrap();
348        assert_eq!(deserialized, schema);
349    }
350
351    #[test]
352    fn test_to_string() {
353        let schema = sample_schema();
354        assert_eq!(
355            String::from(schema),
356            "v1/fastsim-3/conv/ford/fusion/2012/base/r1"
357        );
358    }
359
360    #[test]
361    fn test_from_str() {
362        let s = "v1/fastsim-3/conv/ford/fusion/2012/base/r1";
363        let schema = VehicleSchemaV1::from_str(s).unwrap();
364        assert_eq!(schema, sample_schema());
365    }
366
367    #[test]
368    fn test_from_str_errors() {
369        assert!(VehicleSchemaV1::from_str("v2/fastsim-3/conv/ford/fusion/2012/base/r1").is_err());
370        assert!(VehicleSchemaV1::from_str("v1/fastsim-3/conv/ford/fusion/2012/base").is_err());
371        assert!(VehicleSchemaV1::from_str("v1/bad-3/conv/ford/fusion/2012/base/r1").is_err());
372        assert!(VehicleSchemaV1::from_str("v1/fastsim-3/conv/ford/fusion/2012/base/v1").is_err());
373    }
374
375    #[test]
376    fn test_new_rejects_slash_in_fields() {
377        assert!(VehicleSchemaV1::new(
378            3,
379            "conv".to_string(),
380            "ford".to_string(),
381            "f-150/raptor".to_string(),
382            "2012".to_string(),
383            "base".to_string(),
384            1,
385        )
386        .is_err());
387        assert!(VehicleSchemaV1::new(
388            3,
389            "conv".to_string(),
390            "ford".to_string(),
391            "fusion".to_string(),
392            "2012".to_string(),
393            "base/trim".to_string(),
394            1,
395        )
396        .is_err());
397    }
398
399    #[test]
400    fn test_new_allows_expected_characters() {
401        assert!(VehicleSchemaV1::new(
402            3,
403            "conv".to_string(),
404            "a-b-c-d-e-f0".to_string(),
405            "model-3-long-range".to_string(),
406            "2020".to_string(),
407            "base-v1-2".to_string(),
408            1,
409        )
410        .is_ok());
411    }
412
413    #[test]
414    fn test_new_rejects_disallowed_characters() {
415        assert!(VehicleSchemaV1::new(
416            3,
417            "conv".to_string(),
418            "ford".to_string(),
419            "fusion:se".to_string(),
420            "2012".to_string(),
421            "base".to_string(),
422            1,
423        )
424        .is_err());
425    }
426
427    #[test]
428    fn test_normalize_identifier_simple_cases() {
429        assert_eq!(
430            VehicleSchemaV1::normalize_identifier("Outback XT"),
431            "outback-xt"
432        );
433        assert_eq!(
434            VehicleSchemaV1::normalize_identifier("Model__3   Performance"),
435            "model-3-performance"
436        );
437        assert_eq!(
438            VehicleSchemaV1::normalize_identifier("f-150/raptor"),
439            "f-150-raptor"
440        );
441        assert_eq!(VehicleSchemaV1::normalize_identifier("foo@bar"), "foo-bar");
442        assert_eq!(VehicleSchemaV1::normalize_identifier("foo..bar"), "foo.bar");
443        assert_eq!(
444            VehicleSchemaV1::normalize_identifier("foo.-..bar"),
445            "foo.-.bar"
446        );
447        assert_eq!(VehicleSchemaV1::normalize_identifier("foo/bar"), "foo-bar");
448        assert_eq!(VehicleSchemaV1::normalize_identifier("---"), "");
449    }
450
451    #[test]
452    fn test_normalize_engine_displacement() {
453        assert_eq!(
454            VehicleSchemaV1::normalize_identifier("Golf 1.5 TSI"),
455            "golf-1.5-tsi"
456        );
457        assert_eq!(
458            VehicleSchemaV1::normalize_identifier("F-150 3.5 EcoBoost"),
459            "f-150-3.5-ecoboost"
460        );
461    }
462
463    #[test]
464    fn test_vehicle_model_identifiers_with_engine_displacement() {
465        assert!(VehicleSchemaV1::validate_identifier("golf-1.5tsi"));
466        assert!(VehicleSchemaV1::validate_identifier("f-150-3.5-ecoboost"));
467    }
468
469    #[test]
470    fn test_validate_identifier_passes_for_slug_strings() {
471        assert!(VehicleSchemaV1::validate_identifier("ford"));
472        assert!(VehicleSchemaV1::validate_identifier("model-3"));
473        assert!(VehicleSchemaV1::validate_identifier("golf-1.5tsi"));
474        assert!(VehicleSchemaV1::validate_identifier("2020"));
475        assert!(VehicleSchemaV1::validate_identifier("a1-b2-c3"));
476    }
477
478    #[test]
479    fn test_validate_identifier_fails_for_non_slug_strings() {
480        assert!(!VehicleSchemaV1::validate_identifier("Outback XT"));
481        assert!(!VehicleSchemaV1::validate_identifier("model_3"));
482        assert!(!VehicleSchemaV1::validate_identifier("model+3"));
483        assert!(!VehicleSchemaV1::validate_identifier("model--3"));
484        assert!(!VehicleSchemaV1::validate_identifier("/model3"));
485        assert!(!VehicleSchemaV1::validate_identifier(""));
486        assert!(!VehicleSchemaV1::validate_identifier("-model"));
487    }
488
489    #[test]
490    fn test_build_filepath_output() {
491        let base = std::path::Path::new("/tmp/vehicles-db");
492        let schema = sample_schema();
493        let actual = schema.build_filepath(base, "yaml");
494        let expected = base.join("v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml");
495        assert_eq!(actual, expected);
496    }
497
498    #[test]
499    fn test_build_url_output() {
500        let schema = sample_schema();
501        let actual = schema.build_url(None, "yaml");
502        let expected =
503            "https://raw.githubusercontent.com/NatLabRockies/fastsim-vehicles/main/v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml"
504                .to_string();
505        assert_eq!(actual, expected);
506    }
507}