Skip to main content

fastsim_schema/v1/
search.rs

1use super::IndexEntryV1;
2use serde::{Deserialize, Serialize};
3
4/// Structured search constraints over an `IndexEntryV1` slice.
5///
6/// Every field is an exact-match constraint against the corresponding
7/// `IndexEntryV1` field; `None` means "no constraint on this field." `model`
8/// additionally supports substring matching (case-insensitive) since model
9/// names are the field users are most likely to search partially
10/// (e.g. "f-150" should match "f-150-raptor").
11///
12/// All fields left as `None` (the `Default`) matches every entry.
13#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
14pub struct QueryV1 {
15    pub fastsim_version: Option<u32>,
16    pub powertrain: Option<String>,
17    pub make: Option<String>,
18    pub model: Option<String>,
19    pub year: Option<String>,
20    pub variant: Option<String>,
21}
22
23/// Filter `entries` against `query`, returning references to matches in their
24/// original order.
25///
26/// This is intentionally I/O-free and allocation-light (no cloning of entries)
27/// so it can back both `fastsim-core`'s `Vehicle::from_db` and the wasm-bound
28/// browser search widget from the same implementation.
29pub fn search_v1<'a>(entries: &'a [IndexEntryV1], query: &QueryV1) -> Vec<&'a IndexEntryV1> {
30    entries.iter().filter(|e| matches(e, query)).collect()
31}
32
33fn matches(entry: &IndexEntryV1, query: &QueryV1) -> bool {
34    let model_query = query.model.as_deref().map(|v| v.to_ascii_lowercase());
35
36    query
37        .fastsim_version
38        .is_none_or(|v| entry.fastsim_version == v)
39        && query
40            .powertrain
41            .as_deref()
42            .is_none_or(|v| entry.powertrain.eq_ignore_ascii_case(v))
43        && query
44            .make
45            .as_deref()
46            .is_none_or(|v| entry.make.eq_ignore_ascii_case(v))
47        && model_query
48            .as_deref()
49            .is_none_or(|v| entry.model.to_ascii_lowercase().contains(v))
50        && query.year.as_deref().is_none_or(|v| entry.year == v)
51        && query
52            .variant
53            .as_deref()
54            .is_none_or(|v| entry.variant.eq_ignore_ascii_case(v))
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    fn entry(id: &str) -> IndexEntryV1 {
62        format!("{id}.yaml").parse::<IndexEntryV1>().unwrap()
63    }
64
65    fn sample_entries() -> Vec<IndexEntryV1> {
66        vec![
67            entry("v1/fastsim-3/conv/ford/fusion/2012/base/r1"),
68            entry("v1/fastsim-3/bev/ford/f-150-lightning/2024/base/r1"),
69            entry("v1/fastsim-3/bev/tesla/model-3/2020/base/r1"),
70            entry("v1/fastsim-3/conv/ford/f-150/2018/base/r1"),
71        ]
72    }
73
74    #[test]
75    fn empty_query_matches_everything() {
76        let entries = sample_entries();
77        let results = search_v1(&entries, &QueryV1::default());
78        assert_eq!(results.len(), 4);
79    }
80
81    #[test]
82    fn filters_by_make_case_insensitively() {
83        let entries = sample_entries();
84        let query = QueryV1 {
85            make: Some("FORD".to_string()),
86            ..Default::default()
87        };
88        let results = search_v1(&entries, &query);
89        assert_eq!(results.len(), 3);
90        assert!(results.iter().all(|e| e.make == "ford"));
91    }
92
93    #[test]
94    fn filters_by_powertrain_and_make_together() {
95        let entries = sample_entries();
96        let query = QueryV1 {
97            powertrain: Some("bev".to_string()),
98            make: Some("ford".to_string()),
99            ..Default::default()
100        };
101        let results = search_v1(&entries, &query);
102        assert_eq!(results.len(), 1);
103        assert_eq!(results[0].model, "f-150-lightning");
104    }
105
106    #[test]
107    fn model_substring_match_is_case_insensitive() {
108        let entries = sample_entries();
109        let query = QueryV1 {
110            model: Some("F-150".to_string()),
111            ..Default::default()
112        };
113        let results = search_v1(&entries, &query);
114        assert_eq!(results.len(), 2); // f-150 and f-150-lightning
115    }
116
117    #[test]
118    fn no_matches_returns_empty() {
119        let entries = sample_entries();
120        let query = QueryV1 {
121            make: Some("toyota".to_string()),
122            ..Default::default()
123        };
124        assert!(search_v1(&entries, &query).is_empty());
125    }
126
127    #[test]
128    fn fastsim_version_filters_correctly() {
129        let entries = sample_entries();
130        let query = QueryV1 {
131            fastsim_version: Some(3),
132            ..Default::default()
133        };
134        assert_eq!(search_v1(&entries, &query).len(), 4);
135
136        let query = QueryV1 {
137            fastsim_version: Some(4),
138            ..Default::default()
139        };
140        assert!(search_v1(&entries, &query).is_empty());
141    }
142}