1use std::cmp::Ordering;
4
5use sim_kernel::{CapabilityName, Error, Result};
6
7use crate::{AutoLane, BrandCaps, SiteManifest};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct BrandNeed {
12 pub make: String,
14 pub lanes: Vec<AutoLane>,
16 pub capabilities: Vec<CapabilityName>,
18}
19
20impl BrandNeed {
21 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 pub fn requiring(mut self, capability: CapabilityName) -> Self {
32 self.capabilities.push(capability);
33 self
34 }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct BrandSelection {
40 pub manifest: SiteManifest,
42 pub brand_caps: BrandCaps,
44 pub exact_make: bool,
46 pub lane_surplus: usize,
48}
49
50pub 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
111pub fn select_brand_caps(manifests: &[SiteManifest], need: &BrandNeed) -> Result<BrandCaps> {
113 Ok(select_brand(manifests, need)?.brand_caps)
114}
115
116pub 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}