1use anyhow::{Result, anyhow};
2use std::path::Path;
3
4use crate::models::catalog::package::CatalogInstaller;
5use crate::models::install::engine::EngineInstallReceipt;
6use crate::models::install::installed::InstalledPackage;
7use crate::models::install::installer::InstallerType;
8use crate::models::shared::DeploymentKind;
9
10use super::EngineKind;
11use crate::payload::{
12 DetectedArtifactKind, PayloadKind, classify_payload, probe_downloaded_artifact_kind,
13};
14use crate::windows::{font, msix};
15use crate::{archive, portable};
16
17#[cfg(windows)]
18use crate::windows::{exe, msi};
19
20type InstallFn = fn(&CatalogInstaller, &Path, &Path, &str) -> Result<EngineInstallReceipt>;
21type RemoveFn = fn(&InstalledPackage) -> Result<()>;
22type MatchesInstallerFn = fn(&CatalogInstaller) -> bool;
23
24struct EngineDescriptor {
25 kind: EngineKind,
26 install: InstallFn,
27 remove: RemoveFn,
28 matches_installer: MatchesInstallerFn,
29}
30
31fn matches_msix_installer(installer: &CatalogInstaller) -> bool {
32 installer.kind.is_windows_package()
33}
34
35fn matches_native_exe_installer(installer: &CatalogInstaller) -> bool {
36 installer.kind.is_native_exe_family()
37}
38
39fn matches_font_installer(installer: &CatalogInstaller) -> bool {
40 installer.kind.is_font_family()
41}
42
43#[cfg(windows)]
44fn matches_msi_installer(installer: &CatalogInstaller) -> bool {
45 installer.kind.is_msi_family()
46}
47
48fn matches_archive_installer(installer: &CatalogInstaller) -> bool {
49 installer.kind == InstallerType::Zip
50 || matches!(classify_payload(&installer.url), PayloadKind::Archive(_))
51}
52
53fn matches_portable_installer(installer: &CatalogInstaller) -> bool {
54 installer.kind == InstallerType::Portable
55 && matches!(classify_payload(&installer.url), PayloadKind::Raw)
56}
57
58pub(crate) fn probe_installer_from_download(
59 installer: &CatalogInstaller,
60 download_path: &Path,
61) -> Result<InstallerType> {
62 if installer.kind.is_windows_package() {
63 return Ok(installer.kind);
64 }
65
66 match probe_downloaded_artifact_kind(download_path)? {
67 Some(DetectedArtifactKind::Msi) => Ok(InstallerType::Msi),
68 Some(DetectedArtifactKind::Msix) => Ok(InstallerType::Msix),
69 Some(DetectedArtifactKind::Archive(_)) => Ok(InstallerType::Zip),
70 Some(DetectedArtifactKind::Cab) => Err(anyhow!("CAB archives are not supported")),
71 None => Ok(installer.kind),
72 }
73}
74
75pub(crate) fn resolve_deployment_kind(installer: &CatalogInstaller) -> DeploymentKind {
76 if installer.kind.is_archive() {
77 return installer
78 .nested_kind
79 .map_or(DeploymentKind::Portable, InstallerType::deployment_kind);
80 }
81
82 installer.kind.deployment_kind()
83}
84
85fn msix_install(
86 _installer: &CatalogInstaller,
87 download_path: &Path,
88 install_dir: &Path,
89 package_name: &str,
90) -> Result<EngineInstallReceipt> {
91 msix::install(download_path, install_dir, package_name)
92}
93
94fn native_exe_install(
95 installer: &CatalogInstaller,
96 download_path: &Path,
97 install_dir: &Path,
98 package_name: &str,
99) -> Result<EngineInstallReceipt> {
100 #[cfg(not(windows))]
101 {
102 let _ = (installer, download_path, install_dir, package_name);
103 bail!("native executable installation is only supported on Windows")
104 }
105
106 #[cfg(windows)]
107 {
108 exe::install(installer, download_path, install_dir, package_name)
109 }
110}
111
112fn font_install(
113 installer: &CatalogInstaller,
114 download_path: &Path,
115 install_dir: &Path,
116 package_name: &str,
117) -> Result<EngineInstallReceipt> {
118 #[cfg(not(windows))]
119 {
120 let _ = (installer, download_path, install_dir, package_name);
121 bail!("font installation is only supported on Windows")
122 }
123
124 #[cfg(windows)]
125 {
126 font::install(installer, download_path, install_dir, package_name)
127 }
128}
129
130#[cfg(windows)]
131fn msi_install(
132 _installer: &CatalogInstaller,
133 download_path: &Path,
134 install_dir: &Path,
135 package_name: &str,
136) -> Result<EngineInstallReceipt> {
137 msi::install(download_path, install_dir, package_name)
138}
139
140fn archive_install(
141 installer: &CatalogInstaller,
142 download_path: &Path,
143 install_dir: &Path,
144 _package_name: &str,
145) -> Result<EngineInstallReceipt> {
146 archive::install(download_path, install_dir, &installer.url)
147}
148
149fn portable_install(
150 _installer: &CatalogInstaller,
151 download_path: &Path,
152 install_dir: &Path,
153 package_name: &str,
154) -> Result<EngineInstallReceipt> {
155 portable::install(download_path, install_dir, package_name)
156}
157
158fn msix_remove(package: &InstalledPackage) -> Result<()> {
159 msix::remove(package)
160}
161
162fn native_exe_remove(package: &InstalledPackage) -> Result<()> {
163 #[cfg(not(windows))]
164 {
165 let _ = package;
166 bail!("native executable removal is only supported on Windows")
167 }
168
169 #[cfg(windows)]
170 {
171 exe::remove(package)
172 }
173}
174
175fn font_remove(package: &InstalledPackage) -> Result<()> {
176 #[cfg(not(windows))]
177 {
178 let _ = package;
179 bail!("font removal is only supported on Windows")
180 }
181
182 #[cfg(windows)]
183 {
184 font::remove(package)
185 }
186}
187
188#[cfg(windows)]
189fn msi_remove(package: &InstalledPackage) -> Result<()> {
190 msi::remove(package)
191}
192
193fn archive_remove(package: &InstalledPackage) -> Result<()> {
194 archive::remove(package)
195}
196
197fn portable_remove(package: &InstalledPackage) -> Result<()> {
198 portable::remove(package)
199}
200
201const ENGINE_DESCRIPTORS: &[EngineDescriptor] = &[
205 #[cfg(windows)]
206 EngineDescriptor {
207 kind: EngineKind::Msi,
208 install: msi_install,
209 remove: msi_remove,
210 matches_installer: matches_msi_installer,
211 },
212 EngineDescriptor {
213 kind: EngineKind::Msix,
214 install: msix_install,
215 remove: msix_remove,
216 matches_installer: matches_msix_installer,
217 },
218 EngineDescriptor {
219 kind: EngineKind::NativeExe,
220 install: native_exe_install,
221 remove: native_exe_remove,
222 matches_installer: matches_native_exe_installer,
223 },
224 EngineDescriptor {
225 kind: EngineKind::Font,
226 install: font_install,
227 remove: font_remove,
228 matches_installer: matches_font_installer,
229 },
230 EngineDescriptor {
231 kind: EngineKind::Zip,
232 install: archive_install,
233 remove: archive_remove,
234 matches_installer: matches_archive_installer,
235 },
236 EngineDescriptor {
237 kind: EngineKind::Portable,
238 install: portable_install,
239 remove: portable_remove,
240 matches_installer: matches_portable_installer,
241 },
242];
243
244pub(crate) fn resolve_engine_kind_for_installer(
245 installer: &CatalogInstaller,
246) -> Result<EngineKind> {
247 if matches!(classify_payload(&installer.url), PayloadKind::Cab) {
248 return Err(anyhow!("CAB archives are not supported"));
249 }
250
251 ENGINE_DESCRIPTORS
252 .iter()
253 .find(|descriptor| (descriptor.matches_installer)(installer))
254 .map(|descriptor| descriptor.kind)
255 .ok_or_else(|| anyhow!("unsupported installer type '{}'", installer.kind.as_str()))
256}
257
258pub(crate) fn install(
259 kind: EngineKind,
260 installer: &CatalogInstaller,
261 download_path: &Path,
262 install_dir: &Path,
263 package_name: &str,
264) -> Result<EngineInstallReceipt> {
265 let descriptor = resolve_engine_descriptor(kind)?;
266
267 (descriptor.install)(installer, download_path, install_dir, package_name)
268}
269
270pub(crate) fn remove(kind: EngineKind, package: &InstalledPackage) -> Result<()> {
271 let descriptor = resolve_engine_descriptor(kind)?;
272
273 (descriptor.remove)(package)
274}
275
276fn resolve_engine_descriptor(kind: EngineKind) -> Result<&'static EngineDescriptor> {
277 ENGINE_DESCRIPTORS
278 .iter()
279 .find(|descriptor| descriptor.kind == kind)
280 .ok_or_else(|| anyhow!("unsupported engine kind: {:?}", kind))
281}
282
283#[cfg(test)]
284mod tests {
285 use super::{resolve_deployment_kind, resolve_engine_kind_for_installer};
286 use crate::EngineKind;
287 use crate::models::catalog::package::CatalogInstaller;
288 use crate::models::install::installer::InstallerType;
289 use crate::models::shared::DeploymentKind;
290 use winbrew_testing::{CatalogInstallerBuilderExt as _, catalog_installer};
291
292 fn installer(kind: InstallerType, url: &str) -> CatalogInstaller {
293 catalog_installer("Contoso.App".into(), url).with_kind(kind)
294 }
295
296 #[test]
297 fn resolve_installer_treats_portable_zip_as_zip() {
298 let engine = resolve_engine_kind_for_installer(&installer(
299 InstallerType::Portable,
300 "https://example.invalid/tool.zip",
301 ))
302 .expect("engine should resolve");
303
304 assert_eq!(engine, EngineKind::Zip);
305 }
306
307 #[test]
308 fn resolve_installer_routes_raw_portable_payloads_to_portable() {
309 let engine = resolve_engine_kind_for_installer(&installer(
310 InstallerType::Portable,
311 "https://example.invalid/tool.exe",
312 ))
313 .expect("engine should resolve");
314
315 assert_eq!(engine, EngineKind::Portable);
316 }
317
318 #[test]
319 fn resolve_installer_routes_portable_archive_payloads_to_zip() {
320 let engine = resolve_engine_kind_for_installer(&installer(
321 InstallerType::Portable,
322 "https://example.invalid/tool.tar.gz",
323 ))
324 .expect("engine should resolve");
325
326 assert_eq!(engine, EngineKind::Zip);
327 }
328
329 #[test]
330 fn resolve_installer_routes_portable_gzip_payloads_to_zip() {
331 let engine = resolve_engine_kind_for_installer(&installer(
332 InstallerType::Portable,
333 "https://example.invalid/tool.gz",
334 ))
335 .expect("engine should resolve");
336
337 assert_eq!(engine, EngineKind::Zip);
338 }
339
340 #[test]
341 fn resolve_installer_prefers_msix_for_msix_kind() {
342 let engine = resolve_engine_kind_for_installer(&installer(
343 InstallerType::Msix,
344 "https://example.invalid/package.msix",
345 ))
346 .expect("engine should resolve");
347
348 assert_eq!(engine, EngineKind::Msix);
349 }
350
351 #[test]
352 fn resolve_installer_routes_native_exe_family_to_native_exe() {
353 for kind in [
354 InstallerType::Exe,
355 InstallerType::Inno,
356 InstallerType::Nullsoft,
357 InstallerType::Burn,
358 ] {
359 let engine = resolve_engine_kind_for_installer(&installer(
360 kind,
361 "https://example.invalid/native-installer.exe",
362 ))
363 .expect("engine should resolve");
364
365 assert_eq!(engine, EngineKind::NativeExe);
366 }
367 }
368
369 #[test]
370 fn resolve_installer_prefers_explicit_native_exe_kind_over_archive_url() {
371 let engine = resolve_engine_kind_for_installer(&installer(
372 InstallerType::Exe,
373 "https://example.invalid/native-installer.zip",
374 ))
375 .expect("engine should resolve");
376
377 assert_eq!(engine, EngineKind::NativeExe);
378 }
379
380 #[test]
381 fn resolve_installer_keeps_pwa_unsupported() {
382 let err = resolve_engine_kind_for_installer(&installer(
383 InstallerType::Pwa,
384 "https://example.invalid/special-installer.exe",
385 ))
386 .expect_err("pwa should not route yet");
387
388 assert!(err.to_string().contains("unsupported installer type"));
389 }
390
391 #[test]
392 fn resolve_installer_routes_font_to_font_engine() {
393 let engine = resolve_engine_kind_for_installer(&installer(
394 InstallerType::Font,
395 "https://example.invalid/font.ttf",
396 ))
397 .expect("engine should resolve");
398
399 assert_eq!(engine, EngineKind::Font);
400 }
401
402 #[test]
403 fn resolve_deployment_kind_uses_nested_installer_type_for_zip_archives() {
404 let installer = installer(InstallerType::Zip, "https://example.invalid/package.zip")
405 .with_nested(InstallerType::Msi);
406
407 assert_eq!(
408 resolve_deployment_kind(&installer),
409 DeploymentKind::Installed
410 );
411 }
412
413 #[test]
414 fn resolve_deployment_kind_defaults_native_exe_family_to_installed() {
415 for kind in [
416 InstallerType::Exe,
417 InstallerType::Inno,
418 InstallerType::Nullsoft,
419 InstallerType::Burn,
420 ] {
421 let installer = installer(kind, "https://example.invalid/native-installer.exe");
422
423 assert_eq!(
424 resolve_deployment_kind(&installer),
425 DeploymentKind::Installed
426 );
427 }
428 }
429
430 #[cfg(windows)]
431 #[test]
432 fn resolve_installer_prefers_msi_for_msi_kind() {
433 let engine = resolve_engine_kind_for_installer(&installer(
434 InstallerType::Msi,
435 "https://example.invalid/package.msi",
436 ))
437 .expect("engine should resolve");
438
439 assert_eq!(engine, EngineKind::Msi);
440 }
441}