winbrew_app\operations\doctor/
report.rs

1//! Summary assembly for the doctor report.
2//!
3//! This module turns the raw diagnostics produced by `scan` into the final
4//! [`crate::models::domains::reporting::HealthReport`]. It is responsible for path rendering,
5//! diagnostic ordering, fallback diagnostics when package inventory lookup
6//! fails, and the final error count used by the UI.
7
8use anyhow::Result;
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12use crate::AppContext;
13use crate::database;
14
15use super::scan;
16use crate::models::domains::installed::InstalledPackage;
17use crate::models::domains::reporting::{
18    DiagnosisResult, DiagnosisSeverity, HealthReport, RecoveryFinding,
19};
20use crate::models::reporting::HealthScanTimings;
21
22/// Convert a path into the display string used in the final report.
23///
24/// The conversion is lossy on purpose so the report stays printable even if a
25/// path contains non-UTF-8 bytes.
26fn display_path(path: impl AsRef<Path>) -> String {
27    path.as_ref().to_string_lossy().into_owned()
28}
29
30/// Order diagnostics for the final report.
31///
32/// This is intentionally different from `scan::sort_diagnoses`: scan modules
33/// keep their own source-local ordering, while the final report groups all
34/// collected diagnostics by severity first so the UI reads from errors to
35/// warnings.
36fn sort_report_diagnostics(left: &DiagnosisResult, right: &DiagnosisResult) -> std::cmp::Ordering {
37    left.severity
38        .cmp(&right.severity)
39        .then_with(|| left.error_code.cmp(&right.error_code))
40        .then_with(|| left.description.cmp(&right.description))
41}
42
43/// Load installed packages or convert the failure into a diagnostic entry.
44///
45/// A database lookup failure should not prevent the doctor report from being
46/// generated. Instead, the function returns an empty package list plus a single
47/// error diagnostic that explains why package inventory is unavailable.
48fn collect_packages(
49    packages_result: Result<Vec<InstalledPackage>>,
50) -> (Vec<InstalledPackage>, Vec<DiagnosisResult>) {
51    match packages_result {
52        Ok(packages) => (packages, Vec::new()),
53        Err(err) => (
54            Vec::new(),
55            vec![DiagnosisResult {
56                error_code: "installed_packages_unavailable".to_string(),
57                description: format!("installed packages: unavailable ({err})"),
58                severity: DiagnosisSeverity::Error,
59            }],
60        ),
61    }
62}
63
64/// Collect recovery findings from the initial package-loading diagnostics.
65fn collect_initial_recovery_findings(diagnostics: &[DiagnosisResult]) -> Vec<RecoveryFinding> {
66    diagnostics
67        .iter()
68        .filter_map(RecoveryFinding::from_diagnosis)
69        .collect()
70}
71
72fn measure<T>(operation: impl FnOnce() -> T) -> (T, Duration) {
73    let started_at = Instant::now();
74    let value = operation();
75    (value, started_at.elapsed())
76}
77
78fn sort_recovery_findings(left: &RecoveryFinding, right: &RecoveryFinding) -> std::cmp::Ordering {
79    left.action_group
80        .cmp(&right.action_group)
81        .then_with(|| left.severity.cmp(&right.severity))
82        .then_with(|| left.error_code.cmp(&right.error_code))
83        .then_with(|| left.target_path.cmp(&right.target_path))
84        .then_with(|| left.description.cmp(&right.description))
85}
86
87/// Build a full health report for the current application context.
88///
89/// The function snapshots the current paths, collects installed packages,
90/// scans package directories, journal recovery data, and MSI inventory, then
91/// sorts the resulting diagnostics and computes a final error count. The
92/// returned report is intentionally pre-rendered with display-friendly paths
93/// so the caller can present it directly.
94pub fn health_report(ctx: &AppContext) -> Result<HealthReport> {
95    let paths = &ctx.paths;
96    let started_at = Instant::now();
97    let (conn_result, database_connection_duration) = measure(database::get_conn);
98    let conn = conn_result?;
99
100    let (packages_result, installed_packages_duration) =
101        measure(|| scan::installed_packages(&conn));
102    let (packages, mut diagnostics) = collect_packages(packages_result);
103    let mut recovery_findings = collect_initial_recovery_findings(&diagnostics);
104    let (orphan_scan, orphan_scan_duration) =
105        measure(|| scan::scan_orphaned_install_dirs(&paths.packages, &packages));
106    let (package_scan, package_scan_duration) = measure(|| scan::scan_packages(&packages));
107    let scan::PackageInstallScan {
108        diagnostics: package_diagnostics,
109        recovery_findings: package_recovery_findings,
110    } = package_scan;
111    let (msi_scan, msi_scan_duration) = measure(|| scan::scan_msi_inventory(&conn, &packages));
112    let scan::MsiInventoryScan {
113        diagnostics: msi_diagnostics,
114        recovery_findings: msi_recovery_findings,
115    } = msi_scan;
116    let (journal_scan, journal_scan_duration) =
117        measure(|| scan::scan_package_journals(paths, &packages));
118    let scan::PackageJournalScan {
119        diagnostics: journal_diagnostics,
120        recovery_findings: journal_recovery_findings,
121    } = journal_scan;
122
123    diagnostics.extend(package_diagnostics);
124    diagnostics.extend(msi_diagnostics);
125    diagnostics.extend(orphan_scan.diagnostics);
126    diagnostics.extend(journal_diagnostics);
127    // Merge every scan source first, then sort the final report once.
128    diagnostics.sort_unstable_by(sort_report_diagnostics);
129    recovery_findings.extend(package_recovery_findings);
130    recovery_findings.extend(msi_recovery_findings);
131    recovery_findings.extend(orphan_scan.recovery_findings);
132    recovery_findings.extend(journal_recovery_findings);
133    recovery_findings.sort_unstable_by(sort_recovery_findings);
134
135    let error_count = diagnostics
136        .iter()
137        .filter(|diagnosis| matches!(diagnosis.severity, DiagnosisSeverity::Error))
138        .count();
139
140    Ok(HealthReport {
141        database_path: display_path(&paths.db),
142        database_exists: paths.db.exists(),
143        catalog_database_path: display_path(&paths.catalog_db),
144        catalog_database_exists: paths.catalog_db.exists(),
145        install_root_source: if ctx.root_from_env {
146            "env override".to_string()
147        } else {
148            "config:paths.root".to_string()
149        },
150        install_root: display_path(&paths.root),
151        install_root_exists: paths.root.exists(),
152        packages_dir: display_path(&paths.packages),
153        diagnostics,
154        recovery_findings,
155        scan_timings: HealthScanTimings {
156            database_connection: database_connection_duration,
157            installed_packages: installed_packages_duration,
158            package_scan: package_scan_duration,
159            msi_scan: msi_scan_duration,
160            orphan_scan: orphan_scan_duration,
161            journal_scan: journal_scan_duration,
162        },
163        scan_duration: started_at.elapsed(),
164        error_count,
165    })
166}
167
168#[cfg(test)]
169mod tests {
170    use super::{collect_initial_recovery_findings, collect_packages, sort_report_diagnostics};
171    use crate::models::domains::reporting::{
172        DiagnosisResult, DiagnosisSeverity, RecoveryActionGroup, RecoveryIssueKind,
173    };
174    use anyhow::anyhow;
175
176    #[test]
177    fn collect_packages_converts_errors_into_diagnostics() {
178        let (packages, diagnostics) = collect_packages(Err(anyhow!("database unavailable")));
179
180        assert!(packages.is_empty());
181        assert_eq!(diagnostics.len(), 1);
182        assert_eq!(diagnostics[0].error_code, "installed_packages_unavailable");
183        assert_eq!(diagnostics[0].severity, DiagnosisSeverity::Error);
184        assert!(diagnostics[0].description.contains("database unavailable"));
185    }
186
187    #[test]
188    fn sort_report_diagnostics_keeps_errors_before_warnings() {
189        let mut diagnostics = [
190            DiagnosisResult {
191                error_code: "warning_b".to_string(),
192                description: "warning".to_string(),
193                severity: DiagnosisSeverity::Warning,
194            },
195            DiagnosisResult {
196                error_code: "error_a".to_string(),
197                description: "error".to_string(),
198                severity: DiagnosisSeverity::Error,
199            },
200            DiagnosisResult {
201                error_code: "error_c".to_string(),
202                description: "error".to_string(),
203                severity: DiagnosisSeverity::Error,
204            },
205        ];
206
207        diagnostics.sort_unstable_by(sort_report_diagnostics);
208
209        assert_eq!(diagnostics[0].severity, DiagnosisSeverity::Error);
210        assert_eq!(diagnostics[1].severity, DiagnosisSeverity::Error);
211        assert_eq!(diagnostics[2].severity, DiagnosisSeverity::Warning);
212    }
213
214    #[test]
215    fn collect_packages_keeps_empty_package_lists_empty() {
216        let (packages, diagnostics) = collect_packages(Ok(Vec::new()));
217
218        assert!(packages.is_empty());
219        assert!(diagnostics.is_empty());
220    }
221
222    #[test]
223    fn collect_initial_recovery_findings_maps_policy_issues() {
224        let diagnostics = vec![DiagnosisResult {
225            error_code: "stale_package_journal".to_string(),
226            description: "Contoso.App: recovery journal does not match installed package"
227                .to_string(),
228            severity: DiagnosisSeverity::Error,
229        }];
230
231        let findings = collect_initial_recovery_findings(&diagnostics);
232
233        assert_eq!(findings.len(), 1);
234        assert_eq!(findings[0].issue_kind, RecoveryIssueKind::Conflict);
235        assert_eq!(
236            findings[0].action_group,
237            Some(RecoveryActionGroup::JournalReplay)
238        );
239        assert_eq!(findings[0].severity, DiagnosisSeverity::Error);
240    }
241}