winbrew_cli\commands/
repair.rs

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}