winbrew_app\operations\repair/
plan.rs1use 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
31pub 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}