winbrew_cli\commands/
doctor.rs

1use anyhow::Result;
2use std::io::{self, Write};
3
4use crate::commands::error::CommandError;
5use crate::models::domains::reporting::{
6    DiagnosisResult, DiagnosisSeverity, HealthReport, RecoveryActionGroup, RecoveryFinding,
7};
8use crate::{CommandContext, app::doctor};
9use winbrew_ui::Ui;
10
11/// Runs the system health check command.
12///
13/// When `json_output` is enabled, the report is written to stdout as JSON.
14/// When `warn_as_error` is enabled, warnings produce a non-zero exit code.
15pub fn run(ctx: &CommandContext, json_output: bool, warn_as_error: bool) -> Result<()> {
16    if json_output {
17        let report = doctor::health_report(ctx.app())?;
18        let (_, warnings) = split_diagnostics(&report);
19
20        let mut stdout = io::stdout();
21        write_json(&mut stdout, &report)?;
22
23        if let Some(exit_error) = exit_error(report.error_count, warnings.len(), warn_as_error) {
24            return Err(exit_error);
25        }
26
27        return Ok(());
28    }
29
30    let mut ui = ctx.ui();
31    ui.page_title("System Health Check");
32    let report = ui.spinner("Inspecting environment and installed packages...", || {
33        doctor::health_report(ctx.app())
34    })?;
35    let (errors, warnings) = split_diagnostics(&report);
36    let has_issues = has_report_issues(&errors, &warnings);
37
38    ui.display_key_values(&report_summary(&report));
39    ui.info("");
40    render_results(&mut ui, &errors, &warnings);
41    if has_issues {
42        ui.info("");
43        render_recovery_preview(&mut ui, &report.recovery_findings);
44        ui.info("Suggestion: Try running 'winbrew repair' or reinstalling the affected packages.");
45    }
46
47    if let Some(exit_error) = exit_error(report.error_count, warnings.len(), warn_as_error) {
48        return Err(exit_error);
49    }
50
51    Ok(())
52}
53
54fn report_summary(report: &HealthReport) -> Vec<(String, String)> {
55    let timings = &report.scan_timings;
56
57    vec![
58        ("Database".to_string(), report.database_path.clone()),
59        (
60            "Database exists".to_string(),
61            yes_no(report.database_exists).into(),
62        ),
63        (
64            "Catalog database".to_string(),
65            report.catalog_database_path.clone(),
66        ),
67        (
68            "Catalog database exists".to_string(),
69            yes_no(report.catalog_database_exists).into(),
70        ),
71        (
72            "Install root source".to_string(),
73            report.install_root_source.clone(),
74        ),
75        ("Install root".to_string(), report.install_root.clone()),
76        (
77            "Install root exists".to_string(),
78            yes_no(report.install_root_exists).into(),
79        ),
80        ("Packages dir".to_string(), report.packages_dir.clone()),
81        (
82            "Scan duration".to_string(),
83            format_precise_duration(report.scan_duration),
84        ),
85        (
86            "Database connection".to_string(),
87            format_duration(timings.database_connection),
88        ),
89        (
90            "Installed packages".to_string(),
91            format_duration(timings.installed_packages),
92        ),
93        (
94            "Package scan".to_string(),
95            format_duration(timings.package_scan),
96        ),
97        ("MSI scan".to_string(), format_duration(timings.msi_scan)),
98        (
99            "Orphan scan".to_string(),
100            format_duration(timings.orphan_scan),
101        ),
102        (
103            "Journal scan".to_string(),
104            format_duration(timings.journal_scan),
105        ),
106        (
107            "Recovery findings".to_string(),
108            report.recovery_findings.len().to_string(),
109        ),
110        ("Error count".to_string(), report.error_count.to_string()),
111        (
112            "Total findings".to_string(),
113            report.diagnostics.len().to_string(),
114        ),
115    ]
116}
117
118fn split_diagnostics(report: &HealthReport) -> (Vec<&DiagnosisResult>, Vec<&DiagnosisResult>) {
119    let mut errors = Vec::with_capacity(report.error_count);
120    let mut warnings =
121        Vec::with_capacity(report.diagnostics.len().saturating_sub(report.error_count));
122
123    for entry in &report.diagnostics {
124        match entry.severity {
125            DiagnosisSeverity::Error => errors.push(entry),
126            DiagnosisSeverity::Warning => warnings.push(entry),
127        }
128    }
129
130    (errors, warnings)
131}
132
133fn has_report_issues(errors: &[&DiagnosisResult], warnings: &[&DiagnosisResult]) -> bool {
134    !errors.is_empty() || !warnings.is_empty()
135}
136
137/// Renders grouped diagnostics with errors first and warnings second.
138pub fn render_results<W: std::io::Write>(
139    ui: &mut Ui<W>,
140    errors: &[&DiagnosisResult],
141    warnings: &[&DiagnosisResult],
142) {
143    if errors.is_empty() && warnings.is_empty() {
144        ui.success("Your Winbrew installation is healthy!");
145        return;
146    }
147
148    ui.notice(format!("Issues found: {}", errors.len() + warnings.len()));
149
150    if !errors.is_empty() {
151        ui.error("Errors:");
152        for entry in errors {
153            ui.error(format!("  - [{}] {}", entry.error_code, entry.description));
154        }
155    }
156
157    if !warnings.is_empty() {
158        ui.warn("Warnings:");
159        for entry in warnings {
160            ui.warn(format!("  - [{}] {}", entry.error_code, entry.description));
161        }
162    }
163}
164
165fn render_recovery_preview<W: std::io::Write>(ui: &mut Ui<W>, findings: &[RecoveryFinding]) {
166    let preview_lines = recovery_preview_lines(findings);
167
168    if preview_lines.is_empty() {
169        return;
170    }
171
172    ui.notice("Recovery preview:");
173    for line in preview_lines {
174        ui.info(format!("  - {line}"));
175    }
176
177    ui.info("");
178}
179
180fn recovery_preview_lines(findings: &[RecoveryFinding]) -> Vec<String> {
181    let mut journal_replay = 0usize;
182    let mut orphan_cleanup = 0usize;
183    let mut file_restore = 0usize;
184    let mut reinstall = 0usize;
185    let mut manual_review = 0usize;
186
187    for finding in findings {
188        match finding.action_group {
189            Some(RecoveryActionGroup::JournalReplay) => journal_replay += 1,
190            Some(RecoveryActionGroup::OrphanCleanup) => orphan_cleanup += 1,
191            Some(RecoveryActionGroup::FileRestore) => file_restore += 1,
192            Some(RecoveryActionGroup::Reinstall) => reinstall += 1,
193            None => manual_review += 1,
194        }
195    }
196
197    let mut lines = Vec::new();
198    push_recovery_preview_line(&mut lines, "Journal replay", journal_replay);
199    push_recovery_preview_line(&mut lines, "Orphan cleanup", orphan_cleanup);
200    push_recovery_preview_line(&mut lines, "File restore", file_restore);
201    push_recovery_preview_line(&mut lines, "Reinstall", reinstall);
202    push_recovery_preview_line(&mut lines, "Manual review", manual_review);
203
204    lines
205}
206
207fn push_recovery_preview_line(lines: &mut Vec<String>, label: &str, count: usize) {
208    if count == 0 {
209        return;
210    }
211
212    lines.push(format!("{label}: {}", findings_label(count)));
213}
214
215fn findings_label(count: usize) -> String {
216    format!("{count} finding{}", if count == 1 { "" } else { "s" })
217}
218
219/// Serializes the health report to JSON for machine consumption.
220pub fn write_json<W: Write>(writer: &mut W, report: &HealthReport) -> Result<()> {
221    serde_json::to_writer_pretty(&mut *writer, report)?;
222    writeln!(writer)?;
223    writer.flush()?;
224    Ok(())
225}
226
227/// Renders a yes/no status as a static string.
228fn yes_no(value: bool) -> &'static str {
229    ["no", "yes"][value as usize]
230}
231
232/// Formats a [`std::time::Duration`] into a human-readable string.
233pub fn format_duration(duration: std::time::Duration) -> String {
234    if duration.as_secs() > 0 {
235        format!("{:.2}s", duration.as_secs_f64())
236    } else if duration.as_millis() > 0 {
237        format!("{}ms", duration.as_millis())
238    } else {
239        format!("{}µs", duration.as_micros())
240    }
241}
242
243fn format_precise_duration(duration: std::time::Duration) -> String {
244    if duration.as_secs() > 0 {
245        format!("{:.2}s", duration.as_secs_f64())
246    } else {
247        format!("{}µs", duration.as_micros())
248    }
249}
250
251pub fn exit_error(
252    error_count: usize,
253    warning_count: usize,
254    warn_as_error: bool,
255) -> Option<anyhow::Error> {
256    if error_count > 0 {
257        return Some(
258            CommandError::reported(format!(
259                "system health check found {} error(s)",
260                error_count
261            ))
262            .with_exit_code(2)
263            .into(),
264        );
265    }
266
267    if warn_as_error && warning_count > 0 {
268        return Some(
269            CommandError::reported(format!(
270                "system health check found {} warning(s)",
271                warning_count
272            ))
273            .with_exit_code(1)
274            .into(),
275        );
276    }
277
278    None
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::models::domains::reporting::{
285        DiagnosisSeverity, HealthReport, RecoveryActionGroup, RecoveryFinding, RecoveryIssueKind,
286    };
287    use crate::models::reporting::HealthScanTimings;
288    use std::time::Duration;
289
290    fn sample_report() -> HealthReport {
291        HealthReport {
292            database_path: "db.sqlite".to_string(),
293            database_exists: true,
294            catalog_database_path: "catalog.sqlite".to_string(),
295            catalog_database_exists: true,
296            install_root_source: "config:paths.root".to_string(),
297            install_root: "C:/Tools".to_string(),
298            install_root_exists: true,
299            packages_dir: "C:/Tools/packages".to_string(),
300            diagnostics: Vec::new(),
301            recovery_findings: Vec::new(),
302            scan_timings: HealthScanTimings {
303                database_connection: Duration::from_micros(11),
304                installed_packages: Duration::from_micros(12),
305                package_scan: Duration::from_micros(13),
306                msi_scan: Duration::from_micros(14),
307                orphan_scan: Duration::from_micros(15),
308                journal_scan: Duration::from_micros(16),
309            },
310            scan_duration: Duration::from_micros(812),
311            error_count: 0,
312        }
313    }
314
315    fn recovery_finding(
316        error_code: &str,
317        action_group: Option<RecoveryActionGroup>,
318    ) -> RecoveryFinding {
319        RecoveryFinding {
320            error_code: error_code.to_string(),
321            issue_kind: RecoveryIssueKind::RecoveryTrailMissing,
322            action_group,
323            description: error_code.to_string(),
324            severity: DiagnosisSeverity::Warning,
325            target_path: None,
326        }
327    }
328
329    #[test]
330    fn report_summary_includes_scan_timing_breakdown() {
331        let summary = report_summary(&sample_report());
332
333        assert!(
334            summary
335                .iter()
336                .any(|(label, value)| { label == "Database connection" && value == "11µs" })
337        );
338        assert!(
339            summary
340                .iter()
341                .any(|(label, value)| label == "Journal scan" && value == "16µs")
342        );
343        assert!(
344            summary
345                .iter()
346                .any(|(label, value)| label == "Scan duration" && value == "812µs")
347        );
348    }
349
350    #[test]
351    fn recovery_preview_lines_groups_findings_by_action_group() {
352        let findings = vec![
353            recovery_finding("journal", Some(RecoveryActionGroup::JournalReplay)),
354            recovery_finding("orphan", Some(RecoveryActionGroup::OrphanCleanup)),
355            recovery_finding("manual", None),
356        ];
357
358        let lines = recovery_preview_lines(&findings);
359
360        assert_eq!(
361            lines,
362            vec![
363                "Journal replay: 1 finding".to_string(),
364                "Orphan cleanup: 1 finding".to_string(),
365                "Manual review: 1 finding".to_string(),
366            ]
367        );
368    }
369
370    #[test]
371    fn has_report_issues_detects_only_real_findings() {
372        let empty: Vec<&DiagnosisResult> = Vec::new();
373
374        assert!(!has_report_issues(&empty, &empty));
375
376        let warning = DiagnosisResult {
377            error_code: "orphan_install_directory".to_string(),
378            description: "orphaned package".to_string(),
379            severity: DiagnosisSeverity::Warning,
380        };
381        let warnings = vec![&warning];
382
383        assert!(has_report_issues(&empty, &warnings));
384    }
385}