winbrew_app\operations\install/
types.rs

1//! Error normalization and installer-selection helpers for installation.
2//!
3//! This module keeps the install boundary stable by translating lower-level
4//! failures into a smaller set of user-facing errors. It also wraps catalog
5//! installer selection so the outer workflow does not need to know the catalog
6//! policy for choosing between multiple installer records.
7
8use anyhow::Error;
9use std::io;
10use thiserror::Error;
11
12use super::state::InstallStateError;
13use crate::catalog::{self, InstallerSelectionError, SelectionContext};
14use crate::core::cancel::CancellationError;
15use crate::core::hash::HashError;
16use crate::models::catalog::CatalogInstaller;
17use crate::models::domains::install::InstallFailureClass;
18use crate::models::domains::shared::HashAlgorithm;
19use crate::windows::host::HostProfile;
20
21/// Select the installer that the catalog policy considers best for the package.
22///
23/// The underlying catalog layer owns the actual ranking logic. This helper only
24/// forwards the current host profile and preserves the selector's explicit
25/// failure reasons for the install workflow.
26pub(crate) fn select_installer(
27    installers: &[CatalogInstaller],
28    selection_context: SelectionContext,
29) -> std::result::Result<CatalogInstaller, InstallerSelectionError> {
30    catalog::select_installer(installers, selection_context)
31}
32
33/// User-facing error type produced by the install pipeline.
34///
35/// The variants intentionally map to coarse categories rather than exposing the
36/// raw implementation details from catalog resolution, checksum verification,
37/// cancellation, and rollback. This keeps the CLI behavior predictable while
38/// still preserving enough context for diagnostics and testing.
39#[derive(Debug, Error)]
40pub enum InstallError {
41    #[error("package '{name}' is already installed")]
42    AlreadyInstalled { name: String },
43
44    #[error("package '{name}' is already being installed")]
45    AlreadyInstalling { name: String },
46
47    #[error("package '{name}' is currently updating")]
48    CurrentlyUpdating { name: String },
49
50    #[error("installer checksum mismatch: expected {expected}, got {actual}")]
51    ChecksumMismatch { expected: String, actual: String },
52
53    #[error("{algorithm} checksums are disabled by default for security")]
54    LegacyChecksumAlgorithm { algorithm: HashAlgorithm },
55
56    #[error("catalog package has no installers")]
57    NoInstallers,
58
59    #[error("no installer matches this host ({host})")]
60    NoCompatibleInstaller { host: HostProfile },
61
62    #[error("no installer matches this host's install scope ({host})")]
63    NoScopeCompatibleInstaller { host: HostProfile },
64
65    #[error("command '{command}' is already exposed by package '{package}'")]
66    CommandAlreadyExposed { command: String, package: String },
67
68    #[error(
69        "command '{command}' was claimed by another install while this install was in progress"
70    )]
71    CommandClaimedWhileInProgress { command: String },
72
73    #[error("runtime bootstrap for {runtime} was declined")]
74    RuntimeBootstrapDeclined { runtime: String },
75
76    #[error("cancelled")]
77    Cancelled,
78
79    #[error(transparent)]
80    Unexpected(Error),
81}
82
83/// Convenience result type for install operations.
84pub type Result<T> = std::result::Result<T, InstallError>;
85
86impl InstallError {
87    /// Group the error into a coarse failure class for reporting and rollback.
88    pub fn failure_class(&self) -> InstallFailureClass {
89        match self {
90            Self::AlreadyInstalled { .. }
91            | Self::AlreadyInstalling { .. }
92            | Self::CurrentlyUpdating { .. } => InstallFailureClass::Preflight,
93            Self::ChecksumMismatch { .. } | Self::LegacyChecksumAlgorithm { .. } => {
94                InstallFailureClass::Verification
95            }
96            Self::NoInstallers
97            | Self::NoCompatibleInstaller { .. }
98            | Self::NoScopeCompatibleInstaller { .. }
99            | Self::CommandAlreadyExposed { .. }
100            | Self::RuntimeBootstrapDeclined { .. } => InstallFailureClass::Preflight,
101            Self::CommandClaimedWhileInProgress { .. } => InstallFailureClass::StateTransition,
102            Self::Cancelled => InstallFailureClass::Cancelled,
103            Self::Unexpected(_) => InstallFailureClass::Runtime,
104        }
105    }
106}
107
108impl From<InstallStateError> for InstallError {
109    fn from(value: InstallStateError) -> Self {
110        match value {
111            InstallStateError::AlreadyInstalled { name } => Self::AlreadyInstalled { name },
112            InstallStateError::AlreadyInstalling { name } => Self::AlreadyInstalling { name },
113            InstallStateError::CurrentlyUpdating { name } => Self::CurrentlyUpdating { name },
114            InstallStateError::CommandAlreadyExposed { command, package } => {
115                Self::CommandAlreadyExposed { command, package }
116            }
117            other => Self::Unexpected(Error::new(other)),
118        }
119    }
120}
121
122impl From<HashError> for InstallError {
123    fn from(value: HashError) -> Self {
124        match value {
125            HashError::ChecksumMismatch { expected, actual } => {
126                Self::ChecksumMismatch { expected, actual }
127            }
128            HashError::LegacyChecksumAlgorithm { algorithm } => {
129                Self::LegacyChecksumAlgorithm { algorithm }
130            }
131        }
132    }
133}
134
135impl From<CancellationError> for InstallError {
136    fn from(_: CancellationError) -> Self {
137        Self::Cancelled
138    }
139}
140
141impl From<InstallerSelectionError> for InstallError {
142    fn from(value: InstallerSelectionError) -> Self {
143        match value {
144            InstallerSelectionError::NoInstallers => Self::NoInstallers,
145            InstallerSelectionError::PlatformMismatch { host } => {
146                Self::NoCompatibleInstaller { host }
147            }
148            InstallerSelectionError::ScopeMismatch { host } => {
149                Self::NoScopeCompatibleInstaller { host }
150            }
151        }
152    }
153}
154
155impl From<io::Error> for InstallError {
156    fn from(value: io::Error) -> Self {
157        Self::Unexpected(Error::new(value))
158    }
159}
160
161impl From<Error> for InstallError {
162    fn from(value: Error) -> Self {
163        if let Some(hash_error) = value.downcast_ref::<HashError>() {
164            return Self::from(hash_error.clone());
165        }
166
167        if let Some(selection_error) = value.downcast_ref::<InstallerSelectionError>() {
168            return Self::from(*selection_error);
169        }
170
171        if value.downcast_ref::<CancellationError>().is_some() {
172            return Self::Cancelled;
173        }
174
175        Self::Unexpected(value)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::{InstallError, InstallStateError};
182    use crate::catalog::InstallerSelectionError;
183    use crate::core::cancel::CancellationError;
184    use crate::core::hash::HashError;
185    use crate::models::domains::install::Architecture;
186    use crate::models::domains::install::InstallFailureClass;
187    use crate::models::domains::shared::HashAlgorithm;
188    use crate::windows::host::HostProfile;
189
190    #[test]
191    fn maps_state_conflicts_to_user_facing_errors() {
192        let err = InstallError::from(InstallStateError::AlreadyInstalled {
193            name: "Contoso.App".to_string(),
194        });
195
196        assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
197    }
198
199    #[test]
200    fn maps_hash_errors_to_user_facing_errors() {
201        let err = InstallError::from(HashError::LegacyChecksumAlgorithm {
202            algorithm: HashAlgorithm::Sha1,
203        });
204
205        assert!(matches!(err, InstallError::LegacyChecksumAlgorithm { .. }));
206    }
207
208    #[test]
209    fn maps_cancellation_to_cancelled() {
210        let err = InstallError::from(CancellationError);
211
212        assert!(matches!(err, InstallError::Cancelled));
213    }
214
215    #[test]
216    fn maps_selection_failures_to_user_facing_errors() {
217        let err = InstallError::from(InstallerSelectionError::NoInstallers);
218        assert!(matches!(err, InstallError::NoInstallers));
219
220        let err = InstallError::from(InstallerSelectionError::PlatformMismatch {
221            host: HostProfile {
222                is_server: true,
223                architecture: Architecture::Arm64,
224            },
225        });
226        assert!(matches!(err, InstallError::NoCompatibleInstaller { .. }));
227
228        let err = InstallError::from(InstallerSelectionError::ScopeMismatch {
229            host: HostProfile {
230                is_server: false,
231                architecture: Architecture::X64,
232            },
233        });
234        assert!(matches!(
235            err,
236            InstallError::NoScopeCompatibleInstaller { .. }
237        ));
238    }
239
240    #[test]
241    fn maps_command_conflicts_to_user_facing_errors() {
242        assert_eq!(
243            InstallError::CommandAlreadyExposed {
244                command: "grep".to_string(),
245                package: "Contoso.Grep".to_string(),
246            }
247            .failure_class(),
248            InstallFailureClass::Preflight
249        );
250
251        assert_eq!(
252            InstallError::CommandClaimedWhileInProgress {
253                command: "grep".to_string(),
254            }
255            .failure_class(),
256            InstallFailureClass::StateTransition
257        );
258    }
259
260    #[test]
261    fn failure_class_groups_expected_variants() {
262        assert_eq!(
263            InstallError::from(InstallStateError::AlreadyInstalling {
264                name: "Contoso.App".to_string(),
265            })
266            .failure_class(),
267            InstallFailureClass::Preflight
268        );
269        assert_eq!(
270            InstallError::from(HashError::ChecksumMismatch {
271                expected: "a".to_string(),
272                actual: "b".to_string(),
273            })
274            .failure_class(),
275            InstallFailureClass::Verification
276        );
277        assert_eq!(
278            InstallError::Cancelled.failure_class(),
279            InstallFailureClass::Cancelled
280        );
281        assert_eq!(
282            InstallError::RuntimeBootstrapDeclined {
283                runtime: "7-Zip runtime".to_string(),
284            }
285            .failure_class(),
286            InstallFailureClass::Preflight
287        );
288    }
289}