1use crate::models::domains::catalog::CatalogInstaller;
17use crate::models::domains::install::Architecture;
18use crate::windows::host::HostProfile;
19use thiserror::Error;
20use tracing::debug;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) struct SelectionContext {
25 pub host_profile: HostProfile,
27 pub is_elevated: bool,
29}
30
31impl SelectionContext {
32 pub(crate) fn new(host_profile: HostProfile, is_elevated: bool) -> Self {
34 Self {
35 host_profile,
36 is_elevated,
37 }
38 }
39}
40
41#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
43pub(crate) enum InstallerSelectionError {
44 #[error("catalog package has no installers")]
46 NoInstallers,
47 #[error("no installer matches this host ({host})")]
49 PlatformMismatch { host: HostProfile },
50 #[error("no installer matches this host's install scope ({host})")]
52 ScopeMismatch { host: HostProfile },
53}
54
55pub(crate) fn select_installer(
60 installers: &[CatalogInstaller],
61 selection_context: SelectionContext,
62) -> Result<CatalogInstaller, InstallerSelectionError> {
63 if installers.is_empty() {
64 return Err(InstallerSelectionError::NoInstallers);
65 }
66
67 let compatible_installers: Vec<&CatalogInstaller> = installers
68 .iter()
69 .filter(|installer| {
70 platform_matches_host(
71 installer.platform.as_deref(),
72 selection_context.host_profile,
73 )
74 })
75 .collect();
76
77 if compatible_installers.is_empty() {
78 return Err(InstallerSelectionError::PlatformMismatch {
79 host: selection_context.host_profile,
80 });
81 }
82
83 let scope_compatible_installers =
84 scope_compatible_installers(&compatible_installers, selection_context)?;
85
86 debug!(
87 installer_count = installers.len(),
88 compatible_count = compatible_installers.len(),
89 scope_compatible_count = scope_compatible_installers.len(),
90 host = %selection_context.host_profile,
91 elevated = selection_context.is_elevated,
92 "selecting best installer"
93 );
94
95 let selected = scope_compatible_installers
96 .iter()
97 .find(|installer| installer.arch == selection_context.host_profile.architecture)
98 .copied()
99 .or_else(|| {
100 scope_compatible_installers
101 .iter()
102 .find(|installer| installer.arch == Architecture::Any)
103 .copied()
104 })
105 .or_else(|| scope_compatible_installers.first().copied())
106 .expect("compatible installers should not be empty");
107
108 Ok(selected.clone())
109}
110
111fn scope_compatible_installers<'a>(
112 installers: &'a [&'a CatalogInstaller],
113 selection_context: SelectionContext,
114) -> Result<Vec<&'a CatalogInstaller>, InstallerSelectionError> {
115 let scope_compatible_installers: Vec<&CatalogInstaller> = installers
116 .iter()
117 .copied()
118 .filter(|installer| {
119 scope_matches_host(installer.scope.as_deref(), selection_context.is_elevated)
120 })
121 .collect();
122
123 if scope_compatible_installers.is_empty() {
124 return Err(InstallerSelectionError::ScopeMismatch {
125 host: selection_context.host_profile,
126 });
127 }
128
129 if selection_context.is_elevated {
130 let machine_installers: Vec<&CatalogInstaller> = scope_compatible_installers
131 .iter()
132 .copied()
133 .filter(|installer| {
134 installer_scope_kind(installer.scope.as_deref()) == InstallerScopeKind::Machine
135 })
136 .collect();
137
138 if machine_installers.is_empty() {
139 Ok(scope_compatible_installers)
140 } else {
141 Ok(machine_installers)
142 }
143 } else {
144 Ok(scope_compatible_installers)
145 }
146}
147
148fn platform_matches_host(platform: Option<&str>, host_profile: HostProfile) -> bool {
149 let Some(platform) = platform else {
150 return true;
151 };
152
153 let Ok(platform_values) = serde_json::from_str::<Vec<String>>(platform) else {
154 return false;
155 };
156
157 let accepted_platforms = host_profile.platform_tags();
158
159 platform_values
160 .iter()
161 .map(|value| value.trim())
162 .filter(|value| !value.is_empty())
163 .any(|value| {
164 accepted_platforms
165 .iter()
166 .any(|accepted| value.eq_ignore_ascii_case(accepted))
167 })
168}
169
170fn scope_matches_host(scope: Option<&str>, is_elevated: bool) -> bool {
171 !matches!(installer_scope_kind(scope), InstallerScopeKind::Machine) || is_elevated
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum InstallerScopeKind {
176 User,
177 Machine,
178 Generic,
179}
180
181fn installer_scope_kind(scope: Option<&str>) -> InstallerScopeKind {
182 let Some(scope) = scope.map(str::trim).filter(|value| !value.is_empty()) else {
183 return InstallerScopeKind::Generic;
184 };
185
186 if scope.eq_ignore_ascii_case("machine") {
187 InstallerScopeKind::Machine
188 } else if scope.eq_ignore_ascii_case("user") {
189 InstallerScopeKind::User
190 } else {
191 InstallerScopeKind::Generic
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use anyhow::Result;
199 use winbrew_testing::{CatalogInstallerBuilderExt as _, catalog_installer};
200
201 fn sample_installer(
202 arch: Architecture,
203 kind: winbrew_models::domains::install::InstallerType,
204 platform: Option<&str>,
205 scope: Option<&str>,
206 ) -> CatalogInstaller {
207 let mut installer = catalog_installer("Contoso.App".into(), "https://example.test/app.exe")
208 .with_arch(arch)
209 .with_kind(kind);
210
211 installer.platform = platform.map(str::to_string);
212 installer.scope = scope.map(str::to_string);
213 installer
214 }
215
216 fn selection_context(
217 is_server: bool,
218 architecture: Architecture,
219 is_elevated: bool,
220 ) -> SelectionContext {
221 SelectionContext::new(
222 HostProfile {
223 is_server,
224 architecture,
225 },
226 is_elevated,
227 )
228 }
229
230 fn normal_host(architecture: Architecture, is_elevated: bool) -> SelectionContext {
231 selection_context(false, architecture, is_elevated)
232 }
233
234 fn server_host(architecture: Architecture, is_elevated: bool) -> SelectionContext {
235 selection_context(true, architecture, is_elevated)
236 }
237
238 #[test]
239 fn select_installer_prefers_matching_arch_for_normal_hosts() -> Result<()> {
240 let installers = vec![
241 sample_installer(
242 Architecture::Any,
243 winbrew_models::domains::install::InstallerType::Portable,
244 Some("[\"Windows.Desktop\"]"),
245 None,
246 ),
247 sample_installer(
248 Architecture::X64,
249 winbrew_models::domains::install::InstallerType::Msix,
250 Some("[\"Windows.Desktop\"]"),
251 None,
252 ),
253 sample_installer(
254 Architecture::X86,
255 winbrew_models::domains::install::InstallerType::Zip,
256 Some("[\"Windows.Desktop\"]"),
257 None,
258 ),
259 ];
260
261 let selected = select_installer(&installers, normal_host(Architecture::X64, false))
262 .expect("installer should exist");
263
264 assert_eq!(selected.arch, Architecture::X64);
265 assert_eq!(
266 selected.kind,
267 winbrew_models::domains::install::InstallerType::Msix
268 );
269
270 Ok(())
271 }
272
273 #[test]
274 fn select_installer_prefers_server_platform_on_server_hosts() -> Result<()> {
275 let installers = vec![
276 sample_installer(
277 Architecture::X64,
278 winbrew_models::domains::install::InstallerType::Exe,
279 Some("[\"Windows.Desktop\"]"),
280 None,
281 ),
282 sample_installer(
283 Architecture::X64,
284 winbrew_models::domains::install::InstallerType::Portable,
285 Some("[\"Windows.Server\"]"),
286 None,
287 ),
288 sample_installer(
289 Architecture::Any,
290 winbrew_models::domains::install::InstallerType::Zip,
291 Some("[\"Windows.Server\"]"),
292 None,
293 ),
294 ];
295
296 let selected = select_installer(&installers, server_host(Architecture::X64, false))
297 .expect("installer should exist");
298
299 assert_eq!(selected.arch, Architecture::X64);
300 assert_eq!(
301 selected.kind,
302 winbrew_models::domains::install::InstallerType::Portable
303 );
304
305 Ok(())
306 }
307
308 #[test]
309 fn select_installer_accepts_windows_universal_on_normal_hosts() -> Result<()> {
310 let installers = vec![sample_installer(
311 Architecture::X64,
312 winbrew_models::domains::install::InstallerType::Msix,
313 Some("[\"WINDOWS.UNIVERSAL\"]"),
314 None,
315 )];
316
317 let selected = select_installer(&installers, normal_host(Architecture::X64, false))
318 .expect("installer should exist");
319
320 assert_eq!(
321 selected.kind,
322 winbrew_models::domains::install::InstallerType::Msix
323 );
324 assert_eq!(selected.arch, Architecture::X64);
325
326 Ok(())
327 }
328
329 #[test]
330 fn select_installer_prefers_machine_scope_when_elevated() -> Result<()> {
331 let installers = vec![
332 sample_installer(
333 Architecture::X64,
334 winbrew_models::domains::install::InstallerType::Portable,
335 Some("[\"Windows.Universal\"]"),
336 Some("user"),
337 ),
338 sample_installer(
339 Architecture::X86,
340 winbrew_models::domains::install::InstallerType::Msix,
341 Some("[\"Windows.Universal\"]"),
342 Some("machine"),
343 ),
344 ];
345
346 let selected = select_installer(&installers, normal_host(Architecture::X64, true))
347 .expect("installer should exist");
348
349 assert_eq!(selected.scope.as_deref(), Some("machine"));
350 assert_eq!(selected.arch, Architecture::X86);
351
352 Ok(())
353 }
354
355 #[test]
356 fn select_installer_allows_generic_installers_when_platform_is_missing() -> Result<()> {
357 let installers = vec![
358 sample_installer(
359 Architecture::Any,
360 winbrew_models::domains::install::InstallerType::Portable,
361 None,
362 None,
363 ),
364 sample_installer(
365 Architecture::X64,
366 winbrew_models::domains::install::InstallerType::Msix,
367 Some("[\"Windows.Server\"]"),
368 None,
369 ),
370 ];
371
372 let selected = select_installer(&installers, normal_host(Architecture::Arm64, false))
373 .expect("installer should exist");
374
375 assert_eq!(selected.arch, Architecture::Any);
376 assert_eq!(
377 selected.kind,
378 winbrew_models::domains::install::InstallerType::Portable
379 );
380
381 Ok(())
382 }
383
384 #[test]
385 fn select_installer_returns_no_compatible_installer_when_platforms_do_not_match() {
386 let installers = vec![sample_installer(
387 Architecture::X64,
388 winbrew_models::domains::install::InstallerType::Exe,
389 Some("[\"Windows.Server\"]"),
390 None,
391 )];
392
393 let err = select_installer(&installers, normal_host(Architecture::X64, false))
394 .expect_err("installer should not match");
395
396 assert_eq!(
397 err,
398 InstallerSelectionError::PlatformMismatch {
399 host: HostProfile {
400 is_server: false,
401 architecture: Architecture::X64,
402 },
403 }
404 );
405 }
406
407 #[test]
408 fn select_installer_returns_scope_error_for_machine_only_installers_when_not_elevated() {
409 let installers = vec![sample_installer(
410 Architecture::X64,
411 winbrew_models::domains::install::InstallerType::Exe,
412 Some("[\"Windows.Universal\"]"),
413 Some("machine"),
414 )];
415
416 let err = select_installer(&installers, normal_host(Architecture::X64, false))
417 .expect_err("installer should not match");
418
419 assert_eq!(
420 err,
421 InstallerSelectionError::ScopeMismatch {
422 host: HostProfile {
423 is_server: false,
424 architecture: Architecture::X64,
425 },
426 }
427 );
428 }
429
430 #[test]
431 fn select_installer_returns_no_installers_when_list_is_empty() {
432 let err = select_installer(&[], normal_host(Architecture::X64, false))
433 .expect_err("selection should fail");
434
435 assert!(matches!(err, InstallerSelectionError::NoInstallers));
436 }
437}