1use anyhow::Result;
2use std::io::Write;
3
4use crate::CommandContext;
5use crate::app::doctor;
6use crate::app::install::InstallObserver;
7use crate::app::repair::{self, FileRestoreResolution, RepairPlan};
8use crate::models::domains::catalog::CatalogPackage;
9use winbrew_ui::Ui;
10
11pub fn run(ctx: &CommandContext, yes: bool) -> Result<()> {
12 let mut ui = ctx.ui();
13 ui.page_title("Repair");
14
15 let report = ui.spinner("Inspecting recovery findings...", || {
16 doctor::health_report(ctx.app())
17 })?;
18 let plan = repair::build_repair_plan(&report, &ctx.app().paths.packages);
19
20 if plan.is_empty() {
21 ui.success("No supported recovery actions were found.");
22 if plan.file_restore_count > 0 || plan.reinstall_count > 0 {
23 ui.warn(format!(
24 "Found {} file restore and {} reinstall finding(s), but no package targets were derived.",
25 plan.file_restore_count, plan.reinstall_count
26 ));
27 }
28 return Ok(());
29 }
30
31 let mut applied = 0usize;
32
33 applied += run_journal_replay_group(&mut ui, yes, &plan)?;
34 applied += run_orphan_cleanup_group(&mut ui, yes, &plan)?;
35 applied += run_file_restore_group(&mut ui, ctx, &plan)?;
36 applied += run_reinstall_group(&mut ui, ctx, &plan.reinstall_packages)?;
37
38 if applied == 0 {
39 ui.notice("No recovery actions were applied.");
40 }
41
42 Ok(())
43}
44
45fn run_journal_replay_group<W: Write>(
46 ui: &mut Ui<W>,
47 yes: bool,
48 plan: &RepairPlan,
49) -> Result<usize> {
50 if plan.journal_paths.is_empty() {
51 return Ok(0);
52 }
53
54 ui.info(format!(
55 "Found {} committed journal replay candidate(s).",
56 plan.journal_paths.len()
57 ));
58
59 let journal_targets = repair::prepare_journal_replay_targets(&plan.journal_paths)?;
60 let journal_summary = repair::summarize_journal_replay_targets(&journal_targets);
61 if journal_summary.total > 0 {
62 let message = format!(
63 "Journal command resolution: {} fresh, {} stale, {} unknown.",
64 journal_summary.fresh, journal_summary.stale, journal_summary.unknown
65 );
66
67 if journal_summary.stale > 0 {
68 ui.warn(message);
69 } else {
70 ui.info(message);
71 }
72 }
73
74 if !confirm_group(
75 ui,
76 yes,
77 true,
78 &format!(
79 "Replay {} committed journal(s) into SQLite?",
80 plan.journal_paths.len()
81 ),
82 "Skipped journal replay.",
83 )? {
84 return Ok(0);
85 }
86
87 let replayed = ui.spinner(
88 format!(
89 "Replaying {} committed journal(s)...",
90 plan.journal_paths.len()
91 ),
92 || repair::replay_prepared_journal_targets(&journal_targets),
93 )?;
94
95 ui.success(format!("Replayed {replayed} committed journal(s)."));
96 Ok(replayed)
97}
98
99fn run_orphan_cleanup_group<W: Write>(
100 ui: &mut Ui<W>,
101 yes: bool,
102 plan: &RepairPlan,
103) -> Result<usize> {
104 if plan.orphan_paths.is_empty() {
105 return Ok(0);
106 }
107
108 ui.info(format!(
109 "Found {} orphan install directory candidate(s).",
110 plan.orphan_paths.len()
111 ));
112
113 if !confirm_group(
114 ui,
115 yes,
116 true,
117 &format!(
118 "Remove {} orphan install director{}?",
119 plan.orphan_paths.len(),
120 if plan.orphan_paths.len() == 1 {
121 "y"
122 } else {
123 "ies"
124 }
125 ),
126 "Skipped orphan cleanup.",
127 )? {
128 return Ok(0);
129 }
130
131 let removed = ui.spinner(
132 format!(
133 "Removing {} orphan install director{}...",
134 plan.orphan_paths.len(),
135 if plan.orphan_paths.len() == 1 {
136 "y"
137 } else {
138 "ies"
139 }
140 ),
141 || repair::cleanup_orphan_install_dirs(&plan.orphan_paths),
142 )?;
143
144 ui.success(format!(
145 "Removed {removed} orphan install director{}.",
146 if removed == 1 { "y" } else { "ies" }
147 ));
148 Ok(removed)
149}
150
151fn run_file_restore_group<W: Write>(
152 ui: &mut Ui<W>,
153 ctx: &CommandContext,
154 plan: &RepairPlan,
155) -> Result<usize> {
156 if plan.file_restore_packages.is_empty() {
157 return Ok(0);
158 }
159
160 ui.info(format!(
161 "Found {} file restore package candidate(s).",
162 plan.file_restore_packages.len()
163 ));
164
165 let mut repaired = 0usize;
166
167 for package_target in &plan.file_restore_packages {
168 let target_count = package_target.target_paths.len();
169
170 if !confirm_group(
171 ui,
172 false,
173 false,
174 &format!(
175 "Restore {} file{} for {}?",
176 target_count,
177 if target_count == 1 { "" } else { "s" },
178 package_target.name
179 ),
180 &format!("Skipped file restore for {}.", package_target.name),
181 )? {
182 continue;
183 }
184
185 let resolution =
186 repair::resolve_file_restore_target(&package_target.name, |query, matches| {
187 choose_catalog_package(ui, query, matches)
188 })?;
189
190 match resolution {
191 FileRestoreResolution::Restore(target) => {
192 let restored = ui.spinner(
193 format!(
194 "Restoring {} file{} for {}...",
195 target_count,
196 if target_count == 1 { "" } else { "s" },
197 package_target.name
198 ),
199 || repair::restore_file_restore_target(&target, &package_target.target_paths),
200 )?;
201
202 ui.success(format!(
203 "Restored {} file{} for {}.",
204 restored,
205 if restored == 1 { "" } else { "s" },
206 package_target.name
207 ));
208 repaired += 1;
209 }
210 FileRestoreResolution::Reinstall(target) => {
211 ui.notice(format!(
212 "Catalog version {} differs from installed version {}; reinstalling {} instead.",
213 target.catalog_package.version,
214 target.installed_version,
215 package_target.name
216 ));
217
218 if !confirm_group(
219 ui,
220 false,
221 false,
222 &format!("Reinstall {} instead?", package_target.name),
223 &format!("Skipped reinstall fallback for {}.", package_target.name),
224 )? {
225 continue;
226 }
227
228 let outcome = ui.spinner(
229 format!("Reinstalling {}...", target.catalog_package.name),
230 || {
231 let mut observer = NoopInstallObserver;
232 repair::reinstall_package(ctx.app(), &target.catalog_package, &mut observer)
233 },
234 )?;
235
236 ui.success(format!(
237 "Repaired {} {}.",
238 outcome.result.name, outcome.result.version
239 ));
240 repaired += 1;
241 }
242 }
243 }
244
245 Ok(repaired)
246}
247
248fn run_reinstall_group<W: Write>(
249 ui: &mut Ui<W>,
250 ctx: &CommandContext,
251 package_names: &[String],
252) -> Result<usize> {
253 if package_names.is_empty() {
254 return Ok(0);
255 }
256
257 ui.info(format!(
258 "Found {} reinstall package candidate(s).",
259 package_names.len()
260 ));
261
262 let mut repaired = 0usize;
263
264 for package_name in package_names {
265 if !confirm_group(
266 ui,
267 false,
268 false,
269 &format!("Reinstall {package_name}?"),
270 &format!("Skipped reinstall for {package_name}."),
271 )? {
272 continue;
273 }
274
275 let catalog_package =
276 repair::resolve_repair_catalog_package(package_name, |query, matches| {
277 choose_catalog_package(ui, query, matches)
278 })?;
279
280 let outcome = ui.spinner(format!("Reinstalling {}...", catalog_package.name), || {
281 let mut observer = NoopInstallObserver;
282 repair::reinstall_package(ctx.app(), &catalog_package, &mut observer)
283 })?;
284
285 ui.success(format!(
286 "Repaired {} {}.",
287 outcome.result.name, outcome.result.version
288 ));
289 repaired += 1;
290 }
291
292 Ok(repaired)
293}
294
295fn confirm_group<W: Write>(
296 ui: &mut Ui<W>,
297 yes: bool,
298 allow_auto_yes: bool,
299 prompt: &str,
300 skipped_message: &str,
301) -> Result<bool> {
302 if allow_auto_yes && yes {
303 return Ok(true);
304 }
305
306 if ui.confirm(prompt, false)? {
307 return Ok(true);
308 }
309
310 ui.notice(skipped_message);
311 Ok(false)
312}
313
314fn choose_catalog_package<W: Write>(
315 ui: &mut Ui<W>,
316 query: &str,
317 matches: &[CatalogPackage],
318) -> Result<usize> {
319 let choices = matches
320 .iter()
321 .map(format_catalog_choice)
322 .collect::<Vec<_>>();
323
324 ui.select_index(
325 &format!("Multiple packages matched '{query}'. Choose one:"),
326 &choices,
327 )
328}
329
330fn format_catalog_choice(pkg: &CatalogPackage) -> String {
331 let mut label = String::with_capacity(128);
332 label.push_str(&pkg.name);
333 label.push(' ');
334 label.push_str(&pkg.version.to_string());
335
336 if let Some(publisher) = pkg
337 .publisher
338 .as_deref()
339 .map(str::trim)
340 .filter(|value: &&str| !value.is_empty())
341 {
342 label.push_str(" - ");
343 label.push_str(publisher);
344 }
345
346 if let Some(description) = pkg
347 .description
348 .as_deref()
349 .map(str::trim)
350 .filter(|value: &&str| !value.is_empty())
351 {
352 label.push_str(" (");
353 label.push_str(description);
354 label.push(')');
355 }
356
357 label
358}
359
360struct NoopInstallObserver;
361
362impl InstallObserver for NoopInstallObserver {
363 fn choose_package(
364 &mut self,
365 query: &str,
366 _matches: &[CatalogPackage],
367 ) -> anyhow::Result<usize> {
368 unreachable!("install should not prompt for package selection for '{query}'")
369 }
370}