Skip to main content

fastsim_schema/v1/
index.rs

1//! Index entry structures for vehicle discovery and filtering.
2//!
3//! Defines `IndexEntryV1` for representing vehicles in the `vehicles.jsonl` index file,
4//! with fields for filtering (powertrain, make, model, etc.) and utilities for
5//! parsing index files and building download URLs.
6
7use super::{VehicleSchemaV1, VehicleSchemaV1Error};
8use serde::{Deserialize, Serialize};
9use std::io::Write;
10use std::str::FromStr;
11
12/// A single entry in the vehicle index (i.e. `vehicles.jsonl`).
13///
14/// Every field except `path` is redundant with `id` and exists
15/// only so consumers can filter without re-parsing `id` themselves.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct IndexEntryV1 {
18    pub path: String,
19    pub id: String,
20    pub fastsim_version: u32,
21    pub powertrain: String,
22    pub make: String,
23    pub model: String,
24    pub year: String,
25    pub variant: String,
26    pub revision: u32,
27}
28
29impl IndexEntryV1 {}
30
31impl FromStr for IndexEntryV1 {
32    type Err = VehicleSchemaV1Error;
33
34    /// Parse an entry from a repository-relative file path including its extension
35    /// (e.g. `"v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml"`).
36    ///
37    /// The path minus its extension is parsed and canonically validated as a
38    /// `VehicleSchemaV1` id — an `IndexEntryV1` can only be constructed from a path
39    /// that's already valid, so no separate validation step is needed by
40    /// callers building an index from a directory scan.
41    ///
42    /// # Errors
43    /// Returns `VehicleSchemaV1Error` if the path (minus extension) isn't a valid,
44    /// canonical `v1` schema path.
45    fn from_str(path: &str) -> Result<Self, Self::Err> {
46        let id_str = path.rsplit_once('.').map(|(id, _ext)| id).unwrap_or(path);
47        let schema = id_str.parse::<VehicleSchemaV1>()?;
48
49        Ok(Self {
50            path: path.to_string(),
51            id: schema.to_string(),
52            fastsim_version: schema.fastsim_version,
53            powertrain: schema.powertrain,
54            make: schema.make,
55            model: schema.model,
56            year: schema.year,
57            variant: schema.variant,
58            revision: schema.revision,
59        })
60    }
61}
62
63/// Serialize a slice of entries into JSON Lines, one entry per line
64/// in the given order, written to `writer`. Callers wanting a specific
65/// ordering (e.g. sorted by path) should sort `entries` beforehand —
66/// this function preserves input order rather than imposing one.
67pub fn write_jsonl_v1<W: Write>(
68    writer: &mut W,
69    entries: &[IndexEntryV1],
70) -> Result<(), serde_json::Error> {
71    for entry in entries {
72        serde_json::to_writer(&mut *writer, entry)?;
73        writer.write_all(b"\n").map_err(serde_json::Error::io)?;
74    }
75    Ok(())
76}
77
78/// Parse JSON Lines text (as read from `vehicles.jsonl`, or fetched
79/// client-side in the browser widget) into entries.
80///
81/// Blank lines are skipped; any malformed line surfaces its
82/// `serde_json::Error` rather than being silently dropped.
83pub fn read_jsonl_v1(text: &str) -> Result<Vec<IndexEntryV1>, serde_json::Error> {
84    text.lines()
85        .filter(|line| !line.trim().is_empty())
86        .map(serde_json::from_str)
87        .collect()
88}
89
90#[cfg(test)]
91mod index_tests {
92    use super::*;
93
94    #[test]
95    fn new_parses_and_populates_all_fields() {
96        let entry = "v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml"
97            .parse::<IndexEntryV1>()
98            .unwrap();
99
100        assert_eq!(
101            entry.path,
102            "v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml"
103        );
104        assert_eq!(entry.id, "v1/fastsim-3/conv/ford/fusion/2012/base/r1");
105        assert_eq!(entry.fastsim_version, 3);
106        assert_eq!(entry.powertrain, "conv");
107        assert_eq!(entry.make, "ford");
108        assert_eq!(entry.model, "fusion");
109        assert_eq!(entry.year, "2012");
110        assert_eq!(entry.variant, "base");
111        assert_eq!(entry.revision, 1);
112    }
113
114    #[test]
115    fn new_rejects_invalid_schema_path() {
116        let result = "v1/not-enough-segments.yaml".parse::<IndexEntryV1>();
117        assert!(result.is_err());
118    }
119
120    #[test]
121    fn new_rejects_non_canonical_identifiers() {
122        // "Ford" is not canonical (must be lowercase) — new should surface
123        // the same InvalidIdentifier error SchemaV1::from_str would.
124        let result = "v1/fastsim-3/conv/Ford/fusion/2012/base/r1.yaml".parse::<IndexEntryV1>();
125        assert!(matches!(
126            result,
127            Err(VehicleSchemaV1Error::InvalidIdentifier { .. })
128        ));
129    }
130
131    #[test]
132    fn new_round_trips_through_json() {
133        let entry = "v1/fastsim-3/bev/tesla/model-3/2020/base/r1.yaml"
134            .parse::<IndexEntryV1>()
135            .unwrap();
136        let json = serde_json::to_string(&entry).unwrap();
137        let round_tripped: IndexEntryV1 = serde_json::from_str(&json).unwrap();
138        assert_eq!(entry, round_tripped);
139    }
140}
141
142#[cfg(test)]
143mod jsonl_tests {
144    use super::*;
145
146    fn sample_entries() -> Vec<IndexEntryV1> {
147        vec![
148            "v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml"
149                .parse::<IndexEntryV1>()
150                .unwrap(),
151            "v1/fastsim-3/bev/tesla/model-3/2020/base/r1.yaml"
152                .parse::<IndexEntryV1>()
153                .unwrap(),
154        ]
155    }
156
157    #[test]
158    fn write_then_read_round_trips() {
159        let entries = sample_entries();
160        let mut buf = Vec::new();
161        write_jsonl_v1(&mut buf, &entries).unwrap();
162        let jsonl = String::from_utf8(buf).unwrap();
163        assert_eq!(jsonl.lines().count(), 2);
164
165        let parsed = read_jsonl_v1(&jsonl).unwrap();
166        assert_eq!(parsed, entries);
167    }
168
169    #[test]
170    fn write_jsonl_of_empty_slice_writes_nothing() {
171        let mut buf = Vec::new();
172        write_jsonl_v1(&mut buf, &[]).unwrap();
173        assert!(buf.is_empty());
174    }
175
176    #[test]
177    fn read_jsonl_of_empty_string_is_empty_vec() {
178        let parsed = read_jsonl_v1("").unwrap();
179        assert!(parsed.is_empty());
180    }
181
182    #[test]
183    fn read_jsonl_surfaces_malformed_line_error() {
184        let text = "{\"not\": \"a valid IndexEntryV1\"}\n";
185        assert!(read_jsonl_v1(text).is_err());
186    }
187}