winbrew_engines/
registry.rs

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
201// Native executable and font families must appear before Archive so explicit
202// installer kinds win over archive URL heuristics. Archive must still appear
203// before Portable so archive payloads do not fall back to the raw-copy engine.
204const 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}