1use 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 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}