1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(into = "String", try_from = "String")]
35pub struct VehicleSchemaV1 {
36 pub fastsim_version: u32,
38 pub powertrain: String,
40 pub make: String,
42 pub model: String,
44 pub year: String,
46 pub variant: String,
48 pub revision: u32,
50}
51
52impl std::fmt::Display for VehicleSchemaV1 {
53 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 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 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 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 fn allowed_character(c: char) -> bool {
203 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'
204 }
205
206 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 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 pub fn validate_identifier(s: &str) -> bool {
246 !s.is_empty() && Self::normalize_identifier(s) == s
247 }
248
249 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(¤t.powertrain),
258 make: Self::normalize_identifier(¤t.make),
259 model: Self::normalize_identifier(¤t.model),
260 year: Self::normalize_identifier(¤t.year),
261 variant: Self::normalize_identifier(¤t.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 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 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 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}