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
11pub 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
137pub 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
219pub 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
227fn yes_no(value: bool) -> &'static str {
229 ["no", "yes"][value as usize]
230}
231
232pub 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}