winbrew_app\operations\doctor\scan/
orphan.rs1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::models::domains::installed::InstalledPackage;
5use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
6
7use super::{OrphanInstallScan, ScanResult, sort_diagnoses, sort_recovery_findings};
8
9pub(super) fn scan_orphaned_install_dirs(
16 packages_root: &Path,
17 packages: &[InstalledPackage],
18) -> OrphanInstallScan {
19 let mut known_packages = HashSet::with_capacity(packages.len());
20 known_packages.extend(packages.iter().map(|pkg| pkg.name.as_str()));
21
22 let entries = match std::fs::read_dir(packages_root) {
23 Ok(entries) => entries,
24 Err(err) => {
25 let mut result = ScanResult::default();
26 result.push(
27 DiagnosisResult {
28 error_code: "packages_root_unreadable".to_string(),
29 description: format!(
30 "packages root: unreadable packages directory ({}) - {err}",
31 packages_root.to_string_lossy()
32 ),
33 severity: DiagnosisSeverity::Error,
34 },
35 None,
36 );
37 return result;
38 }
39 };
40
41 let mut result = ScanResult::default();
42
43 for entry in entries.flatten() {
44 let file_type = match entry.file_type() {
45 Ok(file_type) => file_type,
46 Err(_) => continue,
47 };
48
49 if !file_type.is_dir() {
50 continue;
51 }
52
53 let path = entry.path();
54
55 let package_name = match path.file_name().and_then(|value| value.to_str()) {
56 Some(package_name) => package_name,
57 None => continue,
58 };
59
60 if known_packages.contains(package_name) {
61 continue;
62 }
63
64 result.push_orphan(package_name, &path);
65 }
66
67 sort_diagnoses(&mut result.diagnostics);
68 sort_recovery_findings(&mut result.recovery_findings);
69
70 result
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::models::domains::install::InstallerType;
77 use crate::models::domains::installed::{InstalledPackage, PackageStatus};
78 use crate::models::domains::reporting::{RecoveryActionGroup, RecoveryIssueKind};
79 use std::fs;
80 use tempfile::tempdir;
81
82 fn sample_package(name: &str, install_dir: &std::path::Path) -> InstalledPackage {
83 InstalledPackage {
84 name: name.to_string(),
85 version: "1.0.0".to_string(),
86 kind: InstallerType::Portable,
87 deployment_kind: InstallerType::Portable.deployment_kind(),
88 engine_kind: InstallerType::Portable.into(),
89 engine_metadata: None,
90 install_dir: install_dir.to_string_lossy().into_owned(),
91 dependencies: Vec::new(),
92 status: PackageStatus::Ok,
93 installed_at: "2026-04-05T00:00:00Z".to_string(),
94 }
95 }
96
97 #[test]
98 fn scan_orphaned_install_dirs_skips_known_packages() {
99 let temp_dir = tempdir().expect("temp dir should be created");
100 let packages_root = temp_dir.path().join("packages");
101 fs::create_dir_all(&packages_root).expect("packages root should be created");
102
103 let known_package_dir = packages_root.join("Contoso.Known");
104 fs::create_dir_all(&known_package_dir).expect("known package directory should be created");
105
106 let orphan_dir = packages_root.join("Contoso.Orphan");
107 fs::create_dir_all(&orphan_dir).expect("orphan directory should be created");
108
109 let known_package = sample_package("Contoso.Known", &known_package_dir);
110
111 let scan = scan_orphaned_install_dirs(&packages_root, &[known_package]);
112
113 assert_eq!(scan.diagnostics.len(), 1);
114 assert_eq!(scan.diagnostics[0].error_code, "orphan_install_directory");
115 assert_eq!(scan.recovery_findings.len(), 1);
116 assert_eq!(
117 scan.recovery_findings[0].issue_kind,
118 RecoveryIssueKind::IncompleteInstall
119 );
120 assert_eq!(
121 scan.recovery_findings[0].action_group,
122 Some(RecoveryActionGroup::OrphanCleanup)
123 );
124 assert_eq!(
125 scan.recovery_findings[0].target_path.as_deref(),
126 Some(orphan_dir.to_string_lossy().as_ref())
127 );
128 }
129
130 #[test]
131 fn scan_orphaned_install_dirs_ignores_files_in_packages_root() {
132 let temp_dir = tempdir().expect("temp dir should be created");
133 let packages_root = temp_dir.path().join("packages");
134 fs::create_dir_all(&packages_root).expect("packages root should be created");
135
136 let file_path = packages_root.join("README.txt");
137 fs::write(&file_path, b"not a directory").expect("file should be created");
138
139 let scan = scan_orphaned_install_dirs(&packages_root, &[]);
140
141 assert!(scan.diagnostics.is_empty());
142 assert!(scan.recovery_findings.is_empty());
143 }
144
145 #[test]
146 fn scan_orphaned_install_dirs_reports_unreadable_packages_root() {
147 let temp_dir = tempdir().expect("temp dir should be created");
148 let packages_root = temp_dir.path().join("packages");
149 fs::write(&packages_root, b"not a directory")
150 .expect("packages root file should be created");
151
152 let scan = scan_orphaned_install_dirs(&packages_root, &[]);
153
154 assert_eq!(scan.diagnostics.len(), 1);
155 assert_eq!(scan.diagnostics[0].error_code, "packages_root_unreadable");
156 assert_eq!(
157 scan.diagnostics[0].severity,
158 crate::models::domains::reporting::DiagnosisSeverity::Error
159 );
160 assert!(scan.recovery_findings.is_empty());
161 }
162
163 #[test]
164 fn scan_orphaned_install_dirs_returns_empty_for_empty_root() {
165 let temp_dir = tempdir().expect("temp dir should be created");
166 let packages_root = temp_dir.path().join("packages");
167 fs::create_dir_all(&packages_root).expect("packages root should be created");
168
169 let scan = scan_orphaned_install_dirs(&packages_root, &[]);
170
171 assert!(scan.diagnostics.is_empty());
172 assert!(scan.recovery_findings.is_empty());
173 }
174
175 #[test]
176 fn scan_orphaned_install_dirs_detects_directories_without_packages() {
177 let temp_dir = tempdir().expect("temp dir should be created");
178 let packages_root = temp_dir.path().join("packages");
179 fs::create_dir_all(&packages_root).expect("packages root should be created");
180
181 let orphan_dir = packages_root.join("Contoso.Orphan");
182 fs::create_dir_all(&orphan_dir).expect("orphan directory should be created");
183
184 let known_package = sample_package("Contoso.Known", &packages_root.join("Contoso.Known"));
185
186 let scan = scan_orphaned_install_dirs(&packages_root, &[known_package]);
187
188 assert_eq!(scan.diagnostics.len(), 1);
189 assert_eq!(scan.diagnostics[0].error_code, "orphan_install_directory");
190 assert_eq!(
191 scan.diagnostics[0].severity,
192 crate::models::domains::reporting::DiagnosisSeverity::Warning
193 );
194 assert_eq!(scan.recovery_findings.len(), 1);
195 assert_eq!(
196 scan.recovery_findings[0].issue_kind,
197 RecoveryIssueKind::IncompleteInstall
198 );
199 assert_eq!(
200 scan.recovery_findings[0].action_group,
201 Some(RecoveryActionGroup::OrphanCleanup)
202 );
203 assert_eq!(
204 scan.recovery_findings[0].target_path.as_deref(),
205 Some(orphan_dir.to_string_lossy().as_ref())
206 );
207 }
208}