winbrew_cli\commands/
install.rs

1//! Install command wrapper for package resolution, download progress, and
2//! user-facing install outcomes.
3
4use anyhow::Result;
5use std::io;
6use std::path::Path;
7
8use crate::CommandContext;
9use crate::app::install;
10use crate::app::install::InstallError;
11use crate::app::install::InstallObserver;
12use crate::app::install::plan;
13use crate::commands::error::{cancelled, reported_with_hint};
14use crate::models::domains::catalog::CatalogPackage;
15use crate::models::domains::package::PackageRef;
16use winbrew_ui::{ProgressHandle, SpinnerGuard, Ui};
17
18pub fn run(
19    ctx: &CommandContext,
20    query: &[String],
21    ignore_checksum_security: bool,
22    plan_mode: bool,
23) -> Result<()> {
24    let mut ui = ctx.ui();
25    ui.page_title("Install Package");
26
27    let query_text = query.join(" ").trim().to_owned();
28    if query_text.is_empty() {
29        return Err(anyhow::Error::msg("package query cannot be empty"));
30    }
31
32    let package_ref = PackageRef::parse(&query_text).map_err(anyhow::Error::msg)?;
33
34    ui.info(format!("Resolving {query_text}..."));
35
36    if plan_mode {
37        let preview = {
38            let mut observer = PlanInstallUi { ui: &mut ui };
39            match plan::build_install_preview(
40                ctx.app(),
41                package_ref,
42                ignore_checksum_security,
43                &mut observer,
44            ) {
45                Ok(preview) => preview,
46                Err(err) => return handle_install_error(&mut ui, err),
47            }
48        };
49
50        render_install_preview(&mut ui, ctx.app(), &preview, ctx.app().verbosity > 0);
51        return Ok(());
52    }
53
54    let result = run_install(&mut ui, ctx.app(), package_ref, ignore_checksum_security);
55
56    match result {
57        Ok(outcome) => {
58            for algorithm in outcome.legacy_checksum_algorithms {
59                ui.warn(format!(
60                    "This package uses {} checksums. Verification succeeded, but {} is a legacy algorithm.",
61                    algorithm.display_name(),
62                    algorithm.display_name()
63                ));
64            }
65
66            let result = outcome.result;
67            ui.success(format!(
68                "Installed {} {} into {}.",
69                result.name, result.version, result.install_dir
70            ));
71        }
72        Err(err) => return handle_install_error(&mut ui, err),
73    }
74
75    Ok(())
76}
77
78fn format_catalog_choice(pkg: &CatalogPackage) -> String {
79    let mut label = String::with_capacity(128);
80    label.push_str(&pkg.name);
81    label.push(' ');
82    label.push_str(&pkg.version.to_string());
83
84    if let Some(publisher) = pkg
85        .publisher
86        .as_deref()
87        .map(str::trim)
88        .filter(|value: &&str| !value.is_empty())
89    {
90        label.push_str(" - ");
91        label.push_str(publisher);
92    }
93
94    if let Some(description) = pkg
95        .description
96        .as_deref()
97        .map(str::trim)
98        .filter(|value: &&str| !value.is_empty())
99    {
100        label.push_str(" (");
101        label.push_str(description);
102        label.push(')');
103    }
104
105    label
106}
107
108fn render_install_preview<W: io::Write>(
109    ui: &mut Ui<W>,
110    ctx: &crate::AppContext,
111    preview: &plan::InstallPreview,
112    show_temp_root: bool,
113) {
114    ui.notice("Install preview:");
115
116    for line in plan::preview_lines(ctx, preview, show_temp_root) {
117        ui.info(format!("  - {line}"));
118    }
119
120    ui.info("");
121}
122
123fn handle_install_error<W: io::Write>(ui: &mut Ui<W>, err: InstallError) -> Result<()> {
124    match err {
125        InstallError::AlreadyInstalled { name } => {
126            ui.notice(format!("{name} is already installed."));
127        }
128        InstallError::AlreadyInstalling { name } => {
129            ui.warn(format!("{name} is currently being installed."));
130        }
131        InstallError::CurrentlyUpdating { name } => {
132            ui.warn(format!("{name} is currently updating."));
133        }
134        InstallError::ChecksumMismatch { expected, actual } => {
135            let message = format!("Installer checksum mismatch: expected {expected}, got {actual}");
136            ui.error(&message);
137            ui.notice("Hint: re-download the installer or refresh the catalog before retrying.");
138            return Err(reported_with_hint(
139                message,
140                "Re-download the installer or refresh the catalog before retrying.",
141            ));
142        }
143        InstallError::LegacyChecksumAlgorithm { algorithm } => {
144            let message = format!(
145                "{} checksums are disabled by default for security. Re-run with --ignore-checksum-security to install this package.",
146                algorithm.display_name()
147            );
148            ui.error(&message);
149            ui.notice("Hint: re-run with --ignore-checksum-security only if you trust the package source.");
150            return Err(reported_with_hint(
151                message,
152                "Re-run with --ignore-checksum-security only if you trust the package source.",
153            ));
154        }
155        InstallError::NoInstallers => {
156            let message = "This package has no installers in the catalog.".to_string();
157            ui.error(&message);
158            ui.notice("Hint: refresh the catalog or choose a different package.");
159            return Err(reported_with_hint(
160                message,
161                "Refresh the catalog or choose a different package.",
162            ));
163        }
164        InstallError::NoCompatibleInstaller { host } => {
165            let message = format!("No installer in the catalog matches this host ({host}).");
166            ui.error(&message);
167            ui.notice(
168                "Hint: refresh the catalog or pick a package variant built for this machine.",
169            );
170            return Err(reported_with_hint(
171                message,
172                "Refresh the catalog or pick a package variant built for this machine.",
173            ));
174        }
175        InstallError::NoScopeCompatibleInstaller { host } => {
176            let message = format!(
177                "No installer in the catalog matches the required install scope on this host ({host})."
178            );
179            ui.error(&message);
180            ui.notice("Hint: run from an elevated terminal or choose a user-scope installer.");
181            return Err(reported_with_hint(
182                message,
183                "Run from an elevated terminal or choose a user-scope installer.",
184            ));
185        }
186        InstallError::CommandAlreadyExposed { command, package } => {
187            let message = format!("Command '{command}' is already exposed by package '{package}'.");
188            ui.error(&message);
189            ui.notice("Hint: remove the other package or choose a different package.");
190            return Err(reported_with_hint(
191                message,
192                "Remove the other package or choose a different package.",
193            ));
194        }
195        InstallError::CommandClaimedWhileInProgress { command } => {
196            let message = format!(
197                "Command '{command}' was claimed by another install while this install was in progress."
198            );
199            ui.error(&message);
200            ui.notice("Hint: re-run the install once the other install completes.");
201            return Err(reported_with_hint(
202                message,
203                "Re-run the install once the other install completes.",
204            ));
205        }
206        InstallError::RuntimeBootstrapDeclined { runtime } => {
207            let message = format!("{runtime} bootstrap was declined.");
208            ui.warn(&message);
209            ui.notice(
210                "Hint: install 7-Zip system-wide or re-run and allow WinBrew to bootstrap the local runtime.",
211            );
212            return Err(reported_with_hint(
213                message,
214                "Install 7-Zip system-wide or re-run and allow WinBrew to bootstrap the local runtime.",
215            ));
216        }
217        InstallError::Cancelled => {
218            ui.notice("Cancelling and cleaning up...");
219            return Err(cancelled());
220        }
221        InstallError::Unexpected(err) => {
222            return Err(err);
223        }
224    }
225
226    Ok(())
227}
228
229struct InstallUi<'a> {
230    ui: &'a mut Ui<io::Stdout>,
231    progress: ProgressHandle,
232    install_spinner: Option<SpinnerGuard>,
233}
234
235struct PlanInstallUi<'a> {
236    ui: &'a mut Ui<io::Stdout>,
237}
238
239impl InstallObserver for InstallUi<'_> {
240    fn choose_package(&mut self, query: &str, matches: &[CatalogPackage]) -> anyhow::Result<usize> {
241        let choices = matches
242            .iter()
243            .map(format_catalog_choice)
244            .collect::<Vec<_>>();
245
246        self.ui.select_index(
247            &format!("Multiple packages matched '{query}'. Choose one:"),
248            &choices,
249        )
250    }
251
252    fn on_start(&mut self, total_bytes: Option<u64>) {
253        if let Some(total_bytes) = total_bytes {
254            self.progress.set_length(total_bytes);
255        }
256        self.progress.set_message("Downloading installer");
257    }
258
259    fn on_progress(&mut self, downloaded_bytes: u64) {
260        self.progress.inc(downloaded_bytes);
261    }
262
263    fn on_install_start(&mut self, message: &str) {
264        self.install_spinner = Some(self.ui.start_spinner(message));
265    }
266
267    fn on_install_complete(&mut self) {
268        // Clear the phase spinner before commit, journal, and final status output.
269        self.install_spinner = None;
270    }
271
272    fn confirm_runtime_bootstrap(
273        &mut self,
274        runtime_name: &str,
275        target_dir: &Path,
276    ) -> anyhow::Result<bool> {
277        let prompt = format!(
278            "WinBrew needs to download {runtime_name} into {}. Continue?",
279            target_dir.display()
280        );
281
282        self.ui.confirm(&prompt, false)
283    }
284}
285
286impl InstallObserver for PlanInstallUi<'_> {
287    fn choose_package(&mut self, query: &str, matches: &[CatalogPackage]) -> anyhow::Result<usize> {
288        let choices = matches
289            .iter()
290            .map(format_catalog_choice)
291            .collect::<Vec<_>>();
292
293        self.ui.select_index(
294            &format!("Multiple packages matched '{query}'. Choose one:"),
295            &choices,
296        )
297    }
298}
299
300fn run_install(
301    ui: &mut Ui<io::Stdout>,
302    ctx: &crate::AppContext,
303    package_ref: PackageRef,
304    ignore_checksum_security: bool,
305) -> install::Result<install::InstallOutcome> {
306    let progress = ui.progress_bar();
307
308    let mut observer = InstallUi {
309        ui,
310        progress,
311        install_spinner: None,
312    };
313
314    install::run(ctx, package_ref, ignore_checksum_security, &mut observer)
315}