Skip to main content

sim_lib_auto_core/
select.rs

1//! Brand and site selection over open automotive manifest data.
2
3use std::cmp::Ordering;
4
5use sim_kernel::{CapabilityName, Error, Result};
6
7use crate::{AutoLane, BrandCaps, SiteManifest};
8
9/// A requested vehicle make, lanes, and optional capability ceiling.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct BrandNeed {
12    /// Requested vehicle make, such as `volvo` or `mercedes-benz`.
13    pub make: String,
14    /// Lanes required for the current bay action.
15    pub lanes: Vec<AutoLane>,
16    /// Capabilities the selected site must be allowed to hold.
17    pub capabilities: Vec<CapabilityName>,
18}
19
20impl BrandNeed {
21    /// Builds a brand-selection request.
22    pub fn new(make: impl Into<String>, lanes: Vec<AutoLane>) -> Self {
23        Self {
24            make: make.into(),
25            lanes,
26            capabilities: Vec::new(),
27        }
28    }
29
30    /// Adds one required capability to the selection request.
31    pub fn requiring(mut self, capability: CapabilityName) -> Self {
32        self.capabilities.push(capability);
33        self
34    }
35}
36
37/// The selected manifest and derived brand capabilities.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct BrandSelection {
40    /// Selected site manifest.
41    pub manifest: SiteManifest,
42    /// Brand capability data derived from the manifest ceiling.
43    pub brand_caps: BrandCaps,
44    /// True when the manifest directly names the requested make.
45    pub exact_make: bool,
46    /// Count of extra lanes carried by the selected site beyond the request.
47    pub lane_surplus: usize,
48}
49
50/// Returns the best manifest for `need`.
51pub fn select_brand(manifests: &[SiteManifest], need: &BrandNeed) -> Result<BrandSelection> {
52    let mut best: Option<Candidate<'_>> = None;
53    let mut denied = Vec::new();
54    let mut lane_misses = Vec::new();
55
56    for manifest in manifests {
57        let Some(make_match) = make_match(manifest, &need.make) else {
58            continue;
59        };
60        let missing_lanes = missing_lanes(manifest, &need.lanes);
61        if !missing_lanes.is_empty() {
62            lane_misses.push(format!(
63                "{} missing {}",
64                manifest.site,
65                missing_lanes.join(",")
66            ));
67            continue;
68        }
69        let missing_capabilities = missing_capabilities(manifest, &need.capabilities);
70        if !missing_capabilities.is_empty() {
71            denied.push(format!(
72                "{} lacks {}",
73                manifest.site,
74                missing_capabilities.join(",")
75            ));
76            continue;
77        }
78
79        let candidate = Candidate::new(manifest, make_match, need.lanes.len());
80        if best
81            .as_ref()
82            .map(|current| candidate.cmp(current) == Ordering::Greater)
83            .unwrap_or(true)
84        {
85            best = Some(candidate);
86        }
87    }
88
89    if let Some(candidate) = best {
90        return Ok(candidate.into_selection());
91    }
92    if !denied.is_empty() {
93        return Err(Error::Eval(format!(
94            "auto brand/select denied by capability ceiling for make {}: {}",
95            need.make,
96            denied.join("; ")
97        )));
98    }
99    let mut message = format!(
100        "no installed auto site matches vehicle make {} and lanes {}",
101        need.make,
102        lane_names(&need.lanes).join(",")
103    );
104    if !lane_misses.is_empty() {
105        message.push_str("; lane diagnostics: ");
106        message.push_str(&lane_misses.join("; "));
107    }
108    Err(Error::Eval(message))
109}
110
111/// Returns the best manifest's brand capabilities for `need`.
112pub fn select_brand_caps(manifests: &[SiteManifest], need: &BrandNeed) -> Result<BrandCaps> {
113    Ok(select_brand(manifests, need)?.brand_caps)
114}
115
116/// Derives brand capability rows for all installed manifests.
117pub fn installed_brand_caps(manifests: &[SiteManifest]) -> Vec<BrandCaps> {
118    manifests
119        .iter()
120        .map(|manifest| BrandCaps::new(manifest.brand.clone(), manifest.ceiling.clone()))
121        .collect()
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
125enum MakeMatch {
126    Fallback,
127    Exact,
128}
129
130#[derive(Clone, Copy, Debug)]
131struct Candidate<'a> {
132    manifest: &'a SiteManifest,
133    make_match: MakeMatch,
134    lane_surplus: usize,
135}
136
137impl Candidate<'_> {
138    fn new(manifest: &SiteManifest, make_match: MakeMatch, required_lanes: usize) -> Candidate<'_> {
139        Candidate {
140            manifest,
141            make_match,
142            lane_surplus: manifest.lanes.len().saturating_sub(required_lanes),
143        }
144    }
145
146    fn into_selection(self) -> BrandSelection {
147        BrandSelection {
148            manifest: self.manifest.clone(),
149            brand_caps: BrandCaps::new(self.manifest.brand.clone(), self.manifest.ceiling.clone()),
150            exact_make: self.make_match == MakeMatch::Exact,
151            lane_surplus: self.lane_surplus,
152        }
153    }
154}
155
156impl PartialEq for Candidate<'_> {
157    fn eq(&self, other: &Self) -> bool {
158        self.make_match == other.make_match
159            && self.lane_surplus == other.lane_surplus
160            && self.manifest.site == other.manifest.site
161    }
162}
163
164impl Eq for Candidate<'_> {}
165
166impl PartialOrd for Candidate<'_> {
167    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
168        Some(self.cmp(other))
169    }
170}
171
172impl Ord for Candidate<'_> {
173    fn cmp(&self, other: &Self) -> Ordering {
174        self.make_match
175            .cmp(&other.make_match)
176            .then_with(|| other.lane_surplus.cmp(&self.lane_surplus))
177            .then_with(|| other.manifest.site.cmp(&self.manifest.site))
178    }
179}
180
181fn make_match(manifest: &SiteManifest, make: &str) -> Option<MakeMatch> {
182    let make = normalize(make);
183    if manifest
184        .makes
185        .iter()
186        .any(|candidate| normalize(candidate) == make)
187        || normalize(&manifest.brand) == make
188    {
189        return Some(MakeMatch::Exact);
190    }
191    manifest
192        .makes
193        .iter()
194        .any(|candidate| candidate == "*" || normalize(candidate) == "multi-brand")
195        .then_some(MakeMatch::Fallback)
196}
197
198fn missing_lanes(manifest: &SiteManifest, required: &[AutoLane]) -> Vec<String> {
199    let lanes = manifest
200        .lanes
201        .iter()
202        .map(|lane| normalize(lane))
203        .collect::<Vec<_>>();
204    required
205        .iter()
206        .map(|lane| normalize(&lane.name))
207        .filter(|lane| !lanes.contains(lane))
208        .collect()
209}
210
211fn missing_capabilities(manifest: &SiteManifest, required: &[CapabilityName]) -> Vec<String> {
212    required
213        .iter()
214        .filter(|capability| !manifest.ceiling.iter().any(|item| item == *capability))
215        .map(|capability| capability.as_str().to_owned())
216        .collect()
217}
218
219fn lane_names(lanes: &[AutoLane]) -> Vec<String> {
220    lanes.iter().map(|lane| lane.name.clone()).collect()
221}
222
223fn normalize(value: &str) -> String {
224    value.trim().to_ascii_lowercase()
225}
226
227#[cfg(test)]
228mod tests {
229    use sim_kernel::CapabilityName;
230
231    use super::*;
232    use crate::{AUTO_DIAGNOSTICS_READ, AUTO_SERVICE_WRITE};
233
234    #[test]
235    fn select_prefers_exact_make_then_narrower_lane_set() {
236        let fallback = manifest("bosch", "*", vec!["read", "info", "service"]);
237        let exact_broad = manifest("volvo-broad", "volvo", vec!["read", "info", "service"]);
238        let exact_narrow = manifest("vida", "volvo", vec!["read", "info"]);
239        let need = BrandNeed::new("volvo", lanes(&["read", "info"]));
240
241        let selected = select_brand(&[fallback, exact_broad, exact_narrow], &need).unwrap();
242
243        assert_eq!(selected.manifest.site, "vida");
244        assert!(selected.exact_make);
245        assert_eq!(selected.lane_surplus, 0);
246    }
247
248    #[test]
249    fn select_reports_no_match_and_denied_capabilities() {
250        let fallback = manifest("bosch", "*", vec!["read", "info"]);
251        let no_match = select_brand(
252            std::slice::from_ref(&fallback),
253            &BrandNeed::new("saab", lanes(&["parts"])),
254        );
255        assert!(matches!(no_match, Err(Error::Eval(message)) if message.contains("missing parts")));
256
257        let denied = select_brand(
258            &[fallback],
259            &BrandNeed::new("saab", lanes(&["read"]))
260                .requiring(CapabilityName::new(AUTO_SERVICE_WRITE)),
261        );
262        assert!(
263            matches!(denied, Err(Error::Eval(message)) if message.contains("capability ceiling"))
264        );
265    }
266
267    fn manifest(site: &str, make: &str, lanes: Vec<&str>) -> SiteManifest {
268        SiteManifest::new(
269            site,
270            "vehicle-alpha",
271            site,
272            lanes.into_iter().map(str::to_owned).collect(),
273            vec!["modeled".to_owned()],
274            vec!["read/dtc".to_owned()],
275        )
276        .with_makes(vec![make.to_owned()])
277        .with_ceiling(vec![CapabilityName::new(AUTO_DIAGNOSTICS_READ)])
278    }
279
280    fn lanes(items: &[&str]) -> Vec<AutoLane> {
281        items.iter().map(|item| AutoLane::new(*item)).collect()
282    }
283}