winbrew_app\operations\repair/
plan.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use crate::models::domains::reporting::{HealthReport, RecoveryActionGroup};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct FileRestorePackage {
8    pub name: String,
9    pub target_paths: Vec<PathBuf>,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct RepairPlan {
14    pub journal_paths: Vec<PathBuf>,
15    pub orphan_paths: Vec<PathBuf>,
16    pub file_restore_packages: Vec<FileRestorePackage>,
17    pub reinstall_packages: Vec<String>,
18    pub file_restore_count: usize,
19    pub reinstall_count: usize,
20}
21
22impl RepairPlan {
23    pub fn is_empty(&self) -> bool {
24        self.journal_paths.is_empty()
25            && self.orphan_paths.is_empty()
26            && self.file_restore_packages.is_empty()
27            && self.reinstall_packages.is_empty()
28    }
29}
30
31/// Build the grouped recovery plan from a health report.
32pub fn build_repair_plan(report: &HealthReport, packages_root: &Path) -> RepairPlan {
33    let journal_paths = recovery_paths(report, RecoveryActionGroup::JournalReplay);
34    let orphan_paths = recovery_paths(report, RecoveryActionGroup::OrphanCleanup);
35    let file_restore_packages =
36        recovery_file_restore_packages(report, packages_root, RecoveryActionGroup::FileRestore);
37    let mut reinstall_packages =
38        recovery_package_names(report, packages_root, RecoveryActionGroup::Reinstall);
39    reinstall_packages.retain(|package_name| {
40        !file_restore_packages
41            .iter()
42            .any(|candidate| candidate.name == *package_name)
43    });
44
45    RepairPlan {
46        journal_paths,
47        orphan_paths,
48        file_restore_packages,
49        reinstall_packages,
50        file_restore_count: recovery_count(report, RecoveryActionGroup::FileRestore),
51        reinstall_count: recovery_count(report, RecoveryActionGroup::Reinstall),
52    }
53}
54
55fn recovery_paths(report: &HealthReport, action_group: RecoveryActionGroup) -> Vec<PathBuf> {
56    let mut paths = report
57        .recovery_findings
58        .iter()
59        .filter(|finding| finding.action_group == Some(action_group))
60        .filter_map(|finding| finding.target_path.as_ref().map(PathBuf::from))
61        .collect::<Vec<_>>();
62
63    paths.sort();
64    paths.dedup();
65    paths
66}
67
68fn recovery_count(report: &HealthReport, action_group: RecoveryActionGroup) -> usize {
69    report
70        .recovery_findings
71        .iter()
72        .filter(|finding| finding.action_group == Some(action_group))
73        .count()
74}
75
76fn recovery_package_names(
77    report: &HealthReport,
78    packages_root: &Path,
79    action_group: RecoveryActionGroup,
80) -> Vec<String> {
81    let mut package_names = report
82        .recovery_findings
83        .iter()
84        .filter(|finding| finding.action_group == Some(action_group))
85        .filter_map(|finding| {
86            finding.target_path.as_deref().and_then(|target_path| {
87                package_name_from_target_path(packages_root, Path::new(target_path))
88            })
89        })
90        .collect::<Vec<_>>();
91
92    package_names.sort_unstable();
93    package_names.dedup();
94    package_names
95}
96
97fn recovery_file_restore_packages(
98    report: &HealthReport,
99    packages_root: &Path,
100    action_group: RecoveryActionGroup,
101) -> Vec<FileRestorePackage> {
102    let mut package_targets = BTreeMap::<String, Vec<PathBuf>>::new();
103
104    for finding in report
105        .recovery_findings
106        .iter()
107        .filter(|finding| finding.action_group == Some(action_group))
108    {
109        let Some(target_path) = finding.target_path.as_deref() else {
110            continue;
111        };
112
113        let Some(package_name) =
114            package_name_from_target_path(packages_root, Path::new(target_path))
115        else {
116            continue;
117        };
118
119        package_targets
120            .entry(package_name)
121            .or_default()
122            .push(PathBuf::from(target_path));
123    }
124
125    package_targets
126        .into_iter()
127        .map(|(name, mut target_paths)| {
128            target_paths.sort();
129            target_paths.dedup();
130
131            FileRestorePackage { name, target_paths }
132        })
133        .collect()
134}
135
136fn package_name_from_target_path(packages_root: &Path, target_path: &Path) -> Option<String> {
137    let relative_path = target_path.strip_prefix(packages_root).ok()?;
138    let package_name = relative_path.components().next()?.as_os_str().to_str()?;
139
140    if package_name.is_empty() {
141        return None;
142    }
143
144    Some(package_name.to_string())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::models::domains::reporting::{
151        DiagnosisSeverity, HealthReport, RecoveryActionGroup, RecoveryFinding, RecoveryIssueKind,
152    };
153    use crate::models::reporting::HealthScanTimings;
154    use std::path::Path;
155
156    #[test]
157    fn build_repair_plan_groups_targets_and_counts_findings() {
158        let report = HealthReport {
159            database_path: "db.sqlite".to_string(),
160            database_exists: true,
161            catalog_database_path: "catalog.sqlite".to_string(),
162            catalog_database_exists: true,
163            install_root_source: "config".to_string(),
164            install_root: "C:/Tools".to_string(),
165            install_root_exists: true,
166            packages_dir: "C:/Tools/packages".to_string(),
167            diagnostics: Vec::new(),
168            recovery_findings: vec![
169                RecoveryFinding {
170                    error_code: "missing_install_directory".to_string(),
171                    issue_kind: RecoveryIssueKind::DiskDrift,
172                    action_group: Some(RecoveryActionGroup::Reinstall),
173                    description: "pkg reinstall".to_string(),
174                    severity: DiagnosisSeverity::Error,
175                    target_path: Some("C:/Tools/packages/Contoso.App".to_string()),
176                },
177                RecoveryFinding {
178                    error_code: "missing_msi_file".to_string(),
179                    issue_kind: RecoveryIssueKind::DiskDrift,
180                    action_group: Some(RecoveryActionGroup::FileRestore),
181                    description: "pkg file".to_string(),
182                    severity: DiagnosisSeverity::Error,
183                    target_path: Some("C:/Tools/packages/Contoso.App/bin/tool.exe".to_string()),
184                },
185            ],
186            scan_timings: HealthScanTimings::default(),
187            scan_duration: std::time::Duration::from_millis(1),
188            error_count: 2,
189        };
190
191        let plan = build_repair_plan(&report, Path::new("C:/Tools/packages"));
192
193        assert!(plan.journal_paths.is_empty());
194        assert!(plan.orphan_paths.is_empty());
195        assert!(plan.reinstall_packages.is_empty());
196        assert_eq!(plan.file_restore_packages.len(), 1);
197        assert_eq!(plan.file_restore_count, 1);
198        assert_eq!(plan.reinstall_count, 1);
199    }
200}