winbrew_models\install/
engine.rs

1//! Engine metadata, install scope, and engine receipts.
2//!
3//! These types describe the execution layer that actually performs installs or
4//! removals. The engine family keeps platform-specific details, install scope,
5//! uninstall metadata, and MSI inventory snapshots together so storage and
6//! repair code can persist the exact state reported by the engine.
7
8use core::str::FromStr;
9use serde::{Deserialize, Serialize};
10
11use super::installer::InstallerType;
12use crate::msi_inventory::MsiInventorySnapshot;
13use crate::shared::ModelError;
14
15/// The engine family that executed or will execute an install.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum EngineKind {
19    /// A Windows App Installer / MSIX flow.
20    Msix,
21    /// A zip extraction flow.
22    Zip,
23    /// A portable raw-copy flow.
24    Portable,
25    /// A native Windows MSI flow.
26    Msi,
27    /// A non-MSI executable flow.
28    NativeExe,
29    /// A per-user Windows font flow.
30    Font,
31}
32
33/// The install scope reported by Windows package flows.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum InstallScope {
37    /// The package is installed for the current user.
38    Installed,
39    /// The package is provisioned at the machine level.
40    Provisioned,
41}
42
43/// Engine-specific metadata attached to an installation record.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "kind", rename_all = "lowercase")]
46pub enum EngineMetadata {
47    /// Metadata for an MSIX installation.
48    Msix {
49        /// Full package name reported by the Windows package system.
50        package_full_name: String,
51        /// Install scope reported by the engine.
52        scope: InstallScope,
53    },
54    /// Metadata for an MSI installation.
55    Msi {
56        /// MSI product code used by uninstall and repair flows.
57        product_code: String,
58        /// Optional MSI upgrade code.
59        upgrade_code: Option<String>,
60        /// Install scope reported by the engine.
61        scope: InstallScope,
62        /// Registry keys touched by the installer.
63        registry_keys: Vec<String>,
64        /// Shortcuts touched by the installer.
65        shortcuts: Vec<String>,
66    },
67    /// Metadata for a native executable installation.
68    NativeExe {
69        /// Quiet uninstall command published by the installer, if available.
70        #[serde(default, skip_serializing_if = "Option::is_none")]
71        quiet_uninstall_command: Option<String>,
72        /// Standard uninstall command published by the installer, if available.
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        uninstall_command: Option<String>,
75    },
76}
77
78/// Completion record returned by an engine after installation.
79///
80/// The receipt preserves the technical engine kind that executed the install,
81/// the final install directory reported by the engine, and any engine-specific
82/// metadata needed for future removal or repair. MSI engines may also attach
83/// a complete inventory snapshot for database persistence.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct EngineInstallReceipt {
86    /// Engine that produced the receipt.
87    pub engine_kind: EngineKind,
88    /// Final install directory reported by the engine.
89    pub install_dir: String,
90    /// Optional MSI inventory snapshot collected during install.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub msi_inventory_snapshot: Option<MsiInventorySnapshot>,
93    /// Optional engine-specific metadata.
94    pub engine_metadata: Option<EngineMetadata>,
95}
96
97impl EngineInstallReceipt {
98    /// Build a receipt for the given engine kind, install directory, and metadata.
99    pub fn new(
100        engine_kind: EngineKind,
101        install_dir: impl Into<String>,
102        engine_metadata: Option<EngineMetadata>,
103    ) -> Self {
104        Self {
105            engine_kind,
106            install_dir: install_dir.into(),
107            msi_inventory_snapshot: None,
108            engine_metadata,
109        }
110    }
111
112    /// Return the final install directory recorded in the receipt.
113    pub fn install_dir(&self) -> &str {
114        &self.install_dir
115    }
116}
117
118impl EngineKind {
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::Msix => "msix",
122            Self::Zip => "zip",
123            Self::Portable => "portable",
124            Self::Msi => "msi",
125            Self::NativeExe => "nativeexe",
126            Self::Font => "font",
127        }
128    }
129
130    pub fn from_installer_type(kind: InstallerType) -> Self {
131        match kind {
132            InstallerType::Msix | InstallerType::Appx => Self::Msix,
133            InstallerType::Msi | InstallerType::Wix => Self::Msi,
134            InstallerType::Zip => Self::Zip,
135            InstallerType::Portable => Self::Portable,
136            InstallerType::Exe
137            | InstallerType::Inno
138            | InstallerType::Nullsoft
139            | InstallerType::Burn
140            | InstallerType::Pwa => Self::NativeExe,
141            InstallerType::Font => Self::Font,
142        }
143    }
144}
145
146impl InstallScope {
147    pub fn as_str(self) -> &'static str {
148        match self {
149            Self::Installed => "installed",
150            Self::Provisioned => "provisioned",
151        }
152    }
153}
154
155impl EngineMetadata {
156    pub fn msix(package_full_name: impl Into<String>, scope: InstallScope) -> Self {
157        Self::Msix {
158            package_full_name: package_full_name.into(),
159            scope,
160        }
161    }
162
163    pub fn msix_package_full_name(&self) -> Option<&str> {
164        match self {
165            Self::Msix {
166                package_full_name, ..
167            } => Some(package_full_name.as_str()),
168            Self::Msi { .. } | Self::NativeExe { .. } => None,
169        }
170    }
171
172    /// Build native-executable metadata from discovered uninstall commands.
173    pub fn native_exe(
174        quiet_uninstall_command: Option<String>,
175        uninstall_command: Option<String>,
176    ) -> Self {
177        Self::NativeExe {
178            quiet_uninstall_command,
179            uninstall_command,
180        }
181    }
182
183    /// Return the quiet uninstall command if the metadata contains one.
184    pub fn native_exe_quiet_uninstall_command(&self) -> Option<&str> {
185        match self {
186            Self::NativeExe {
187                quiet_uninstall_command: Some(command),
188                ..
189            } => Some(command.as_str()),
190            _ => None,
191        }
192    }
193
194    /// Return the best available uninstall command for a native executable.
195    pub fn native_exe_uninstall_command(&self) -> Option<&str> {
196        match self {
197            Self::NativeExe {
198                quiet_uninstall_command: Some(command),
199                ..
200            } => Some(command.as_str()),
201            Self::NativeExe {
202                uninstall_command: Some(command),
203                ..
204            } => Some(command.as_str()),
205            _ => None,
206        }
207    }
208}
209
210impl From<InstallerType> for EngineKind {
211    fn from(value: InstallerType) -> Self {
212        Self::from_installer_type(value)
213    }
214}
215
216impl FromStr for EngineKind {
217    type Err = ModelError;
218
219    fn from_str(s: &str) -> Result<Self, Self::Err> {
220        match s.trim().to_ascii_lowercase().as_str() {
221            "msix" => Ok(Self::Msix),
222            "zip" => Ok(Self::Zip),
223            "portable" => Ok(Self::Portable),
224            "msi" => Ok(Self::Msi),
225            "nativeexe" => Ok(Self::NativeExe),
226            "font" => Ok(Self::Font),
227            other => Err(ModelError::invalid_enum_value("engine.kind", other)),
228        }
229    }
230}
231
232impl FromStr for InstallScope {
233    type Err = ModelError;
234
235    fn from_str(s: &str) -> Result<Self, Self::Err> {
236        match s.trim().to_ascii_lowercase().as_str() {
237            "installed" => Ok(Self::Installed),
238            "provisioned" => Ok(Self::Provisioned),
239            other => Err(ModelError::invalid_enum_value("engine.scope", other)),
240        }
241    }
242}
243
244impl core::fmt::Display for EngineKind {
245    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
246        f.write_str(self.as_str())
247    }
248}
249
250impl From<EngineKind> for String {
251    fn from(value: EngineKind) -> Self {
252        value.to_string()
253    }
254}
255
256impl core::fmt::Display for InstallScope {
257    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
258        f.write_str(self.as_str())
259    }
260}
261
262impl From<InstallScope> for String {
263    fn from(value: InstallScope) -> Self {
264        value.to_string()
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::{EngineKind, EngineMetadata};
271    use crate::install::installer::InstallerType;
272    use core::str::FromStr;
273
274    #[test]
275    fn engine_kind_rejects_exe_alias() {
276        let err = EngineKind::from_str("exe").expect_err("exe should not parse as an engine kind");
277
278        assert!(err.to_string().contains("invalid engine.kind: exe"));
279    }
280
281    #[test]
282    fn engine_kind_parses_font() {
283        assert_eq!(
284            EngineKind::from_str("font").expect("font"),
285            EngineKind::Font
286        );
287        assert_eq!(EngineKind::Font.to_string(), "font");
288    }
289
290    #[test]
291    fn engine_kind_round_trips_font_to_installer_type() {
292        assert_eq!(InstallerType::from(EngineKind::Font), InstallerType::Font);
293    }
294
295    #[test]
296    fn native_exe_metadata_prefers_quiet_uninstall_command() {
297        let metadata = EngineMetadata::native_exe(
298            Some("C:\\Apps\\Demo\\uninstall.exe /S".to_string()),
299            Some("C:\\Apps\\Demo\\uninstall.exe".to_string()),
300        );
301
302        assert_eq!(
303            metadata.native_exe_quiet_uninstall_command(),
304            Some("C:\\Apps\\Demo\\uninstall.exe /S")
305        );
306        assert_eq!(
307            metadata.native_exe_uninstall_command(),
308            Some("C:\\Apps\\Demo\\uninstall.exe /S")
309        );
310    }
311
312    #[test]
313    fn native_exe_metadata_falls_back_to_uninstall_command() {
314        let metadata =
315            EngineMetadata::native_exe(None, Some("C:\\Apps\\Demo\\uninstall.exe".to_string()));
316
317        assert_eq!(metadata.native_exe_quiet_uninstall_command(), None);
318        assert_eq!(
319            metadata.native_exe_uninstall_command(),
320            Some("C:\\Apps\\Demo\\uninstall.exe")
321        );
322        assert_eq!(metadata.msix_package_full_name(), None);
323    }
324}