winbrew_models\reporting/
report.rs

1//! Health and recovery report models.
2
3use serde::{Deserialize, Serialize, Serializer};
4use std::time::Duration;
5
6use super::diagnostics::DiagnosisResult;
7
8/// Recovery issue buckets used to classify doctor findings.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum RecoveryIssueKind {
12    /// The repair trail or journal was missing.
13    RecoveryTrailMissing,
14    /// The install is incomplete.
15    IncompleteInstall,
16    /// A recovery conflict exists between stored state and the filesystem.
17    Conflict,
18    /// The filesystem content drifted from recorded expectations.
19    DiskDrift,
20}
21
22/// Recovery action group used to drive repair UI choices.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum RecoveryActionGroup {
26    /// Replay a committed journal.
27    JournalReplay,
28    /// Remove orphan directories.
29    OrphanCleanup,
30    /// Restore individual files.
31    FileRestore,
32    /// Reinstall the package.
33    Reinstall,
34}
35
36/// A recovery-oriented interpretation of a diagnostic.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct RecoveryFinding {
39    /// Stable diagnostic code mirrored from the originating diagnostic.
40    pub error_code: String,
41    /// High-level issue classification.
42    pub issue_kind: RecoveryIssueKind,
43    /// Optional user-facing action grouping.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub action_group: Option<RecoveryActionGroup>,
46    /// Human-readable description of the recovery issue.
47    pub description: String,
48    /// Severity inherited from the originating diagnostic.
49    pub severity: super::diagnostics::DiagnosisSeverity,
50    /// Optional target path for repair actions.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub target_path: Option<String>,
53}
54
55impl RecoveryFinding {
56    /// Map a diagnostic into a recovery finding when the error code is actionable.
57    pub fn from_diagnosis(diagnosis: &DiagnosisResult) -> Option<Self> {
58        let (issue_kind, action_group) = match diagnosis.error_code.as_str() {
59            "missing_install_directory" | "install_directory_not_a_directory" => (
60                RecoveryIssueKind::DiskDrift,
61                Some(RecoveryActionGroup::Reinstall),
62            ),
63            "install_directory_permission_denied" | "install_directory_unreadable" => (
64                RecoveryIssueKind::DiskDrift,
65                Some(RecoveryActionGroup::Reinstall),
66            ),
67            "missing_msi_file"
68            | "msi_file_not_a_file"
69            | "msi_file_unreadable"
70            | "msi_file_permission_denied"
71            | "msi_file_hash_mismatch"
72            | "msi_file_hash_unavailable" => (
73                RecoveryIssueKind::DiskDrift,
74                Some(RecoveryActionGroup::FileRestore),
75            ),
76            "missing_msi_inventory_snapshot"
77            | "msi_inventory_unreadable"
78            | "pkgdb_unreadable"
79            | "incomplete_package_journal"
80            | "unreadable_package_journal"
81            | "malformed_package_journal"
82            | "missing_journal_metadata" => (RecoveryIssueKind::RecoveryTrailMissing, None),
83            "orphan_install_directory" => (
84                RecoveryIssueKind::IncompleteInstall,
85                Some(RecoveryActionGroup::OrphanCleanup),
86            ),
87            "orphan_package_journal" => (
88                RecoveryIssueKind::IncompleteInstall,
89                Some(RecoveryActionGroup::JournalReplay),
90            ),
91            "stale_package_journal" | "trailing_package_journal" => (
92                RecoveryIssueKind::Conflict,
93                Some(RecoveryActionGroup::JournalReplay),
94            ),
95            _ => return None,
96        };
97
98        Some(Self {
99            error_code: diagnosis.error_code.clone(),
100            issue_kind,
101            action_group,
102            description: diagnosis.description.clone(),
103            severity: diagnosis.severity,
104            target_path: None,
105        })
106    }
107
108    /// Attach a filesystem target path to the finding.
109    pub fn with_target_path(mut self, target_path: impl Into<String>) -> Self {
110        self.target_path = Some(target_path.into());
111        self
112    }
113}
114
115/// Timing breakdown for the doctor scan pipeline.
116///
117/// These fields are serialized in microseconds and exposed with `*_micros`
118/// JSON field names so the unit is explicit while keeping the values numeric.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
120pub struct HealthScanTimings {
121    /// Time spent opening the database connection.
122    #[serde(
123        rename = "database_connection_micros",
124        serialize_with = "serialize_duration_micros"
125    )]
126    pub database_connection: Duration,
127    /// Time spent loading installed packages.
128    #[serde(
129        rename = "installed_packages_micros",
130        serialize_with = "serialize_duration_micros"
131    )]
132    pub installed_packages: Duration,
133    /// Time spent validating package install directories.
134    #[serde(
135        rename = "package_scan_micros",
136        serialize_with = "serialize_duration_micros"
137    )]
138    pub package_scan: Duration,
139    /// Time spent validating MSI inventory snapshots and files.
140    #[serde(
141        rename = "msi_scan_micros",
142        serialize_with = "serialize_duration_micros"
143    )]
144    pub msi_scan: Duration,
145    /// Time spent checking for orphaned package directories.
146    #[serde(
147        rename = "orphan_scan_micros",
148        serialize_with = "serialize_duration_micros"
149    )]
150    pub orphan_scan: Duration,
151    /// Time spent scanning committed package journals.
152    #[serde(
153        rename = "journal_scan_micros",
154        serialize_with = "serialize_duration_micros"
155    )]
156    pub journal_scan: Duration,
157}
158
159/// The full health report emitted by the doctor workflow.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161pub struct HealthReport {
162    /// Filesystem path to the Winbrew database.
163    pub database_path: String,
164    /// Whether the database path exists.
165    pub database_exists: bool,
166    /// Filesystem path to the catalog database.
167    pub catalog_database_path: String,
168    /// Whether the catalog database path exists.
169    pub catalog_database_exists: bool,
170    /// Where the install root came from in configuration resolution.
171    pub install_root_source: String,
172    /// Filesystem path to the install root.
173    pub install_root: String,
174    /// Whether the install root exists.
175    pub install_root_exists: bool,
176    /// Display path for the packages directory.
177    pub packages_dir: String,
178    /// Sorted diagnostics collected during the scan.
179    pub diagnostics: Vec<DiagnosisResult>,
180    /// Recovery findings derived from the diagnostics.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub recovery_findings: Vec<RecoveryFinding>,
183    /// Timing breakdown for the scan pipeline.
184    pub scan_timings: HealthScanTimings,
185    /// Total scan duration.
186    #[serde(
187        rename = "scan_duration_micros",
188        serialize_with = "serialize_duration_micros"
189    )]
190    pub scan_duration: Duration,
191    /// Count of diagnostics with error severity.
192    pub error_count: usize,
193}
194
195/// Runtime report sections rendered by the info command.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct RuntimeReport {
198    /// Ordered report sections.
199    pub sections: Vec<ReportSection>,
200}
201
202/// A titled section of key/value runtime report data.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct ReportSection {
205    /// Section title.
206    pub title: String,
207    /// Key/value entries in display order.
208    pub entries: Vec<(String, String)>,
209}
210
211impl RuntimeReport {
212    /// Build a runtime report from pre-computed sections.
213    pub fn new(sections: Vec<ReportSection>) -> Self {
214        Self { sections }
215    }
216}
217
218fn serialize_duration_micros<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
219where
220    S: Serializer,
221{
222    let micros = duration.as_micros().min(u64::MAX as u128) as u64;
223    serializer.serialize_u64(micros)
224}