use super::*;
mod error;
mod index;
mod search;
#[cfg(feature = "wasm")]
mod wasm;
pub use error::VehicleSchemaV1Error;
pub use index::{read_jsonl_v1, write_jsonl_v1, IndexEntryV1};
pub use search::{search_v1, QueryV1};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct VehicleSchemaV1 {
pub fastsim_version: u32,
pub powertrain: String,
pub make: String,
pub model: String,
pub year: String,
pub variant: String,
pub revision: u32,
}
impl std::fmt::Display for VehicleSchemaV1 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.path_segments().join("/"))
}
}
impl From<VehicleSchemaV1> for String {
fn from(s: VehicleSchemaV1) -> Self {
s.to_string()
}
}
impl std::str::FromStr for VehicleSchemaV1 {
type Err = VehicleSchemaV1Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let raw = Self::parse_structural(s)?;
Self::new(
raw.fastsim_version,
raw.powertrain,
raw.make,
raw.model,
raw.year,
raw.variant,
raw.revision,
)
}
}
impl TryFrom<String> for VehicleSchemaV1 {
type Error = VehicleSchemaV1Error;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl VehicleSchemaV1 {
pub(crate) fn parse_structural(s: &str) -> Result<Self, VehicleSchemaV1Error> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 8 {
return Err(VehicleSchemaV1Error::SegmentCount {
input: s.to_string(),
actual: parts.len(),
});
}
if parts[0] != "v1" {
return Err(VehicleSchemaV1Error::WrongSchemaPrefix {
found: parts[0].to_string(),
});
}
let fastsim_version = parts[1]
.strip_prefix("fastsim-")
.ok_or_else(|| VehicleSchemaV1Error::MissingFastsimVersionPrefix {
segment: parts[1].to_string(),
})?
.parse::<u32>()
.map_err(|source| VehicleSchemaV1Error::InvalidFastsimVersion {
segment: parts[1].to_string(),
source,
})?;
let revision = parts[7]
.strip_prefix('r')
.ok_or_else(|| VehicleSchemaV1Error::MissingRevisionPrefix {
segment: parts[7].to_string(),
})?
.parse::<u32>()
.map_err(|source| VehicleSchemaV1Error::InvalidRevision {
segment: parts[7].to_string(),
source,
})?;
Ok(Self {
fastsim_version,
powertrain: parts[2].to_string(),
make: parts[3].to_string(),
model: parts[4].to_string(),
year: parts[5].to_string(),
variant: parts[6].to_string(),
revision,
})
}
pub fn new(
fastsim_version: u32,
powertrain: String,
make: String,
model: String,
year: String,
variant: String,
revision: u32,
) -> Result<Self, VehicleSchemaV1Error> {
for (field, segment) in [
("powertrain", &powertrain),
("make", &make),
("model", &model),
("year", &year),
("variant", &variant),
] {
if !Self::validate_identifier(segment) {
return Err(VehicleSchemaV1Error::InvalidIdentifier {
field,
value: segment.to_string(),
suggestion: Self::normalize_identifier(segment),
});
}
}
Ok(Self {
fastsim_version,
powertrain,
make,
model,
year,
variant,
revision,
})
}
fn allowed_character(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'
}
pub fn normalize_identifier(s: &str) -> String {
let mut result = String::new();
let mut previous_was_dash = false;
let mut previous_was_dot = false;
for c in s.to_ascii_lowercase().chars() {
let c = match c {
c if Self::allowed_character(c) => c,
_ => '-',
};
if c == '-' {
if previous_was_dash {
continue;
}
previous_was_dash = true;
previous_was_dot = false;
} else if c == '.' {
if previous_was_dot {
continue;
}
previous_was_dot = true;
previous_was_dash = false;
} else {
previous_was_dash = false;
previous_was_dot = false;
}
result.push(c);
}
result.trim_matches(|c| c == '-' || c == '.').to_string()
}
pub fn validate_identifier(s: &str) -> bool {
!s.is_empty() && Self::normalize_identifier(s) == s
}
pub fn suggest_normalized_path(raw: &str) -> Option<(String, String)> {
let current = Self::parse_structural(raw).ok()?;
let suggested = Self {
fastsim_version: current.fastsim_version,
powertrain: Self::normalize_identifier(¤t.powertrain),
make: Self::normalize_identifier(¤t.make),
model: Self::normalize_identifier(¤t.model),
year: Self::normalize_identifier(¤t.year),
variant: Self::normalize_identifier(¤t.variant),
revision: current.revision,
};
let current_path = current.to_string();
let suggested_path = suggested.to_string();
if current_path != suggested_path {
Some((current_path, suggested_path))
} else {
None
}
}
pub fn path_segments(&self) -> [String; 8] {
[
"v1".to_string(),
format!("fastsim-{}", self.fastsim_version),
self.powertrain.clone(),
self.make.clone(),
self.model.clone(),
self.year.clone(),
self.variant.clone(),
format!("r{}", self.revision),
]
}
pub fn build_filepath<P: AsRef<std::path::Path>>(
&self,
base_dir: P,
extension: &str,
) -> std::path::PathBuf {
base_dir.as_ref().join(format!("{}.{}", self, extension))
}
pub fn build_url(&self, base_url: Option<&str>, extension: &str) -> String {
format!(
"{}/{}.{}",
base_url
.map(|s| s.trim_end_matches('/'))
.unwrap_or(DEFAULT_DB_URL),
self,
extension
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_schema() -> VehicleSchemaV1 {
VehicleSchemaV1::new(
3,
"conv".to_string(),
"ford".to_string(),
"fusion".to_string(),
"2012".to_string(),
"base".to_string(),
1,
)
.unwrap()
}
#[test]
fn test_serde_round_trip() {
let schema = sample_schema();
let serialized = serde_json::to_string(&schema).unwrap();
assert_eq!(serialized, "\"v1/fastsim-3/conv/ford/fusion/2012/base/r1\"");
let deserialized: VehicleSchemaV1 = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, schema);
}
#[test]
fn test_to_string() {
let schema = sample_schema();
assert_eq!(
String::from(schema),
"v1/fastsim-3/conv/ford/fusion/2012/base/r1"
);
}
#[test]
fn test_from_str() {
let s = "v1/fastsim-3/conv/ford/fusion/2012/base/r1";
let schema = VehicleSchemaV1::from_str(s).unwrap();
assert_eq!(schema, sample_schema());
}
#[test]
fn test_from_str_errors() {
assert!(VehicleSchemaV1::from_str("v2/fastsim-3/conv/ford/fusion/2012/base/r1").is_err());
assert!(VehicleSchemaV1::from_str("v1/fastsim-3/conv/ford/fusion/2012/base").is_err());
assert!(VehicleSchemaV1::from_str("v1/bad-3/conv/ford/fusion/2012/base/r1").is_err());
assert!(VehicleSchemaV1::from_str("v1/fastsim-3/conv/ford/fusion/2012/base/v1").is_err());
}
#[test]
fn test_new_rejects_slash_in_fields() {
assert!(VehicleSchemaV1::new(
3,
"conv".to_string(),
"ford".to_string(),
"f-150/raptor".to_string(),
"2012".to_string(),
"base".to_string(),
1,
)
.is_err());
assert!(VehicleSchemaV1::new(
3,
"conv".to_string(),
"ford".to_string(),
"fusion".to_string(),
"2012".to_string(),
"base/trim".to_string(),
1,
)
.is_err());
}
#[test]
fn test_new_allows_expected_characters() {
assert!(VehicleSchemaV1::new(
3,
"conv".to_string(),
"a-b-c-d-e-f0".to_string(),
"model-3-long-range".to_string(),
"2020".to_string(),
"base-v1-2".to_string(),
1,
)
.is_ok());
}
#[test]
fn test_new_rejects_disallowed_characters() {
assert!(VehicleSchemaV1::new(
3,
"conv".to_string(),
"ford".to_string(),
"fusion:se".to_string(),
"2012".to_string(),
"base".to_string(),
1,
)
.is_err());
}
#[test]
fn test_normalize_identifier_simple_cases() {
assert_eq!(
VehicleSchemaV1::normalize_identifier("Outback XT"),
"outback-xt"
);
assert_eq!(
VehicleSchemaV1::normalize_identifier("Model__3 Performance"),
"model-3-performance"
);
assert_eq!(
VehicleSchemaV1::normalize_identifier("f-150/raptor"),
"f-150-raptor"
);
assert_eq!(VehicleSchemaV1::normalize_identifier("foo@bar"), "foo-bar");
assert_eq!(VehicleSchemaV1::normalize_identifier("foo..bar"), "foo.bar");
assert_eq!(
VehicleSchemaV1::normalize_identifier("foo.-..bar"),
"foo.-.bar"
);
assert_eq!(VehicleSchemaV1::normalize_identifier("foo/bar"), "foo-bar");
assert_eq!(VehicleSchemaV1::normalize_identifier("---"), "");
}
#[test]
fn test_normalize_engine_displacement() {
assert_eq!(
VehicleSchemaV1::normalize_identifier("Golf 1.5 TSI"),
"golf-1.5-tsi"
);
assert_eq!(
VehicleSchemaV1::normalize_identifier("F-150 3.5 EcoBoost"),
"f-150-3.5-ecoboost"
);
}
#[test]
fn test_vehicle_model_identifiers_with_engine_displacement() {
assert!(VehicleSchemaV1::validate_identifier("golf-1.5tsi"));
assert!(VehicleSchemaV1::validate_identifier("f-150-3.5-ecoboost"));
}
#[test]
fn test_validate_identifier_passes_for_slug_strings() {
assert!(VehicleSchemaV1::validate_identifier("ford"));
assert!(VehicleSchemaV1::validate_identifier("model-3"));
assert!(VehicleSchemaV1::validate_identifier("golf-1.5tsi"));
assert!(VehicleSchemaV1::validate_identifier("2020"));
assert!(VehicleSchemaV1::validate_identifier("a1-b2-c3"));
}
#[test]
fn test_validate_identifier_fails_for_non_slug_strings() {
assert!(!VehicleSchemaV1::validate_identifier("Outback XT"));
assert!(!VehicleSchemaV1::validate_identifier("model_3"));
assert!(!VehicleSchemaV1::validate_identifier("model+3"));
assert!(!VehicleSchemaV1::validate_identifier("model--3"));
assert!(!VehicleSchemaV1::validate_identifier("/model3"));
assert!(!VehicleSchemaV1::validate_identifier(""));
assert!(!VehicleSchemaV1::validate_identifier("-model"));
}
#[test]
fn test_build_filepath_output() {
let base = std::path::Path::new("/tmp/vehicles-db");
let schema = sample_schema();
let actual = schema.build_filepath(base, "yaml");
let expected = base.join("v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml");
assert_eq!(actual, expected);
}
#[test]
fn test_build_url_output() {
let schema = sample_schema();
let actual = schema.build_url(None, "yaml");
let expected =
"https://raw.githubusercontent.com/NatLabRockies/fastsim-vehicles/main/v1/fastsim-3/conv/ford/fusion/2012/base/r1.yaml"
.to_string();
assert_eq!(actual, expected);
}
}