1use std::path::{Path, PathBuf};
2
3use crate::sizing;
4use crate::types::{Category, CleanableItem, Risk};
5
6#[derive(Debug, Clone)]
8pub struct AppInfo {
9 pub name: String,
11 pub bundle_id: String,
13 pub app_path: PathBuf,
15}
16
17pub fn list_apps() -> Vec<AppInfo> {
20 list_apps_in("/Applications")
21}
22
23pub fn list_apps_in(applications_dir: &str) -> Vec<AppInfo> {
25 let dir = Path::new(applications_dir);
26 let Ok(entries) = std::fs::read_dir(dir) else {
27 return Vec::new();
28 };
29
30 let mut apps: Vec<AppInfo> = entries
31 .flatten()
32 .filter(|e| e.path().extension().is_some_and(|ext| ext == "app"))
33 .filter_map(|e| read_app_info(&e.path()))
34 .collect();
35
36 apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
37 apps
38}
39
40fn read_app_info(app_path: &Path) -> Option<AppInfo> {
42 let plist_path = app_path.join("Contents/Info.plist");
43 let value = plist::Value::from_file(&plist_path).ok()?;
44 let dict = value.as_dictionary()?;
45
46 let bundle_id = dict.get("CFBundleIdentifier")?.as_string()?.to_string();
47
48 let name = dict
50 .get("CFBundleName")
51 .and_then(|v| v.as_string())
52 .or_else(|| dict.get("CFBundleDisplayName").and_then(|v| v.as_string()))
53 .map(|s| s.to_string())
54 .unwrap_or_else(|| {
55 app_path
56 .file_stem()
57 .map(|s| s.to_string_lossy().to_string())
58 .unwrap_or_default()
59 });
60
61 Some(AppInfo {
62 name,
63 bundle_id,
64 app_path: app_path.to_path_buf(),
65 })
66}
67
68pub fn find_app(query: &str, apps: &[AppInfo]) -> Vec<AppInfo> {
74 let query_lower = query.to_lowercase();
75 apps.iter()
76 .filter(|app| {
77 app.name.to_lowercase().contains(&query_lower)
78 || app.bundle_id.to_lowercase().contains(&query_lower)
79 })
80 .cloned()
81 .collect()
82}
83
84const TRACE_DIRS: &[(&str, TraceKind)] = &[
87 ("Application Support", TraceKind::DirMatchNameOrId),
88 ("Caches", TraceKind::DirMatchNameOrId),
89 ("Containers", TraceKind::DirMatchId),
90 ("Group Containers", TraceKind::DirMatchIdSuffix),
91 ("Preferences", TraceKind::PlistMatchId),
92 ("Preferences/ByHost", TraceKind::PlistGlobId),
93 ("Saved Application State", TraceKind::DirMatchIdSuffix),
94 ("HTTPStorages", TraceKind::DirMatchNameOrId),
95 ("WebKit", TraceKind::DirMatchNameOrId),
96 ("Application Scripts", TraceKind::DirMatchId),
97 ("Logs", TraceKind::DirMatchNameOrId),
98 ("LaunchAgents", TraceKind::PlistGlobId),
99 ("Cookies", TraceKind::FileGlobId),
100 ("SyncedPreferences", TraceKind::PlistMatchId),
101];
102
103#[derive(Debug, Clone, Copy)]
104enum TraceKind {
105 DirMatchNameOrId,
107 DirMatchId,
109 DirMatchIdSuffix,
111 PlistMatchId,
113 PlistGlobId,
115 FileGlobId,
117}
118
119pub fn find_app_traces(app: &AppInfo, home: &str) -> Vec<CleanableItem> {
122 let library = PathBuf::from(format!("{home}/Library"));
123 let mut items = Vec::new();
124
125 for &(subdir, kind) in TRACE_DIRS {
126 let dir = library.join(subdir);
127 if !dir.exists() {
128 continue;
129 }
130 match kind {
131 TraceKind::DirMatchNameOrId => {
132 find_dir_traces(&dir, app, true, &mut items);
133 }
134 TraceKind::DirMatchId => {
135 find_dir_traces(&dir, app, false, &mut items);
136 }
137 TraceKind::DirMatchIdSuffix => {
138 find_dir_suffix_traces(&dir, app, &mut items);
139 }
140 TraceKind::PlistMatchId => {
141 find_plist_trace(&dir, app, &mut items);
142 }
143 TraceKind::PlistGlobId => {
144 find_plist_glob_traces(&dir, app, &mut items);
145 }
146 TraceKind::FileGlobId => {
147 find_file_glob_traces(&dir, app, &mut items);
148 }
149 }
150 }
151
152 if app.app_path.exists() {
154 let stats = sizing::dir_stats(&app.app_path);
155 items.push(CleanableItem {
156 category: Category::AppCache(app.name.clone()),
157 path: app.app_path.clone(),
158 size: stats.size,
159 risk: Risk::Medium,
160 regenerates: false,
161 regeneration_hint: Some("Re-download from developer or App Store".into()),
162 last_modified: stats.last_modified,
163 description: format!("{}.app bundle", app.name),
164 cleanup_command: None,
165 });
166 }
167
168 items.sort_by(|a, b| b.size.cmp(&a.size));
169 items
170}
171
172fn find_dir_traces(parent: &Path, app: &AppInfo, match_name: bool, items: &mut Vec<CleanableItem>) {
174 let Ok(entries) = std::fs::read_dir(parent) else {
175 return;
176 };
177 let id_lower = app.bundle_id.to_lowercase();
178 let name_lower = app.name.to_lowercase();
179
180 for entry in entries.flatten() {
181 let entry_name = entry.file_name().to_string_lossy().to_string();
182 let entry_lower = entry_name.to_lowercase();
183
184 let matched = entry_lower == id_lower || (match_name && entry_lower == name_lower);
185
186 if matched {
187 let path = entry.path();
188 add_trace_item(&path, app, items);
189 }
190 }
191}
192
193fn find_dir_suffix_traces(parent: &Path, app: &AppInfo, items: &mut Vec<CleanableItem>) {
195 let Ok(entries) = std::fs::read_dir(parent) else {
196 return;
197 };
198 let id_lower = app.bundle_id.to_lowercase();
199
200 for entry in entries.flatten() {
201 let entry_name = entry.file_name().to_string_lossy().to_string();
202 let entry_lower = entry_name.to_lowercase();
203
204 if entry_lower.contains(&id_lower) {
206 let path = entry.path();
207 add_trace_item(&path, app, items);
208 }
209 }
210}
211
212fn find_plist_trace(parent: &Path, app: &AppInfo, items: &mut Vec<CleanableItem>) {
214 let plist_path = parent.join(format!("{}.plist", app.bundle_id));
215 if plist_path.exists() {
216 add_trace_item(&plist_path, app, items);
217 }
218}
219
220fn find_plist_glob_traces(parent: &Path, app: &AppInfo, items: &mut Vec<CleanableItem>) {
222 let Ok(entries) = std::fs::read_dir(parent) else {
223 return;
224 };
225 let id_lower = app.bundle_id.to_lowercase();
226
227 for entry in entries.flatten() {
228 let name = entry.file_name().to_string_lossy().to_string();
229 let name_lower = name.to_lowercase();
230 if name_lower.contains(&id_lower) && name_lower.ends_with(".plist") {
231 add_trace_item(&entry.path(), app, items);
232 }
233 }
234}
235
236fn find_file_glob_traces(parent: &Path, app: &AppInfo, items: &mut Vec<CleanableItem>) {
238 let Ok(entries) = std::fs::read_dir(parent) else {
239 return;
240 };
241 let id_lower = app.bundle_id.to_lowercase();
242
243 for entry in entries.flatten() {
244 let name = entry.file_name().to_string_lossy().to_string();
245 if name.to_lowercase().contains(&id_lower) {
246 add_trace_item(&entry.path(), app, items);
247 }
248 }
249}
250
251fn add_trace_item(path: &Path, app: &AppInfo, items: &mut Vec<CleanableItem>) {
253 let (size, last_modified) = if path.is_dir() {
254 let stats = sizing::dir_stats(path);
255 (stats.size, stats.last_modified)
256 } else if let Ok(meta) = path.metadata() {
257 (meta.len(), meta.modified().ok())
258 } else {
259 return;
260 };
261
262 let risk = if path
267 .extension()
268 .is_some_and(|e| e == "plist" || e == "binarycookies")
269 {
270 Risk::None
271 } else {
272 Risk::Low
273 };
274
275 let description = format!(
276 "{}: {}",
277 app.name,
278 path.file_name()
279 .map(|n| n.to_string_lossy().to_string())
280 .unwrap_or_else(|| path.to_string_lossy().to_string()),
281 );
282
283 let risk = crate::safety::adjust_risk(path, risk);
285
286 items.push(CleanableItem {
287 category: Category::AppCache(app.name.clone()),
288 path: path.to_path_buf(),
289 size,
290 risk,
291 regenerates: false,
292 regeneration_hint: None,
293 last_modified,
294 description,
295 cleanup_command: None,
296 });
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::fs;
303
304 fn make_app_info() -> AppInfo {
305 AppInfo {
306 name: "Docker".into(),
307 bundle_id: "com.docker.docker".into(),
308 app_path: PathBuf::from("/Applications/Docker.app"),
309 }
310 }
311
312 #[test]
313 fn find_app_by_name() {
314 let apps = vec![
315 make_app_info(),
316 AppInfo {
317 name: "Safari".into(),
318 bundle_id: "com.apple.Safari".into(),
319 app_path: PathBuf::from("/Applications/Safari.app"),
320 },
321 ];
322
323 let results = find_app("docker", &apps);
324 assert_eq!(results.len(), 1);
325 assert_eq!(results[0].name, "Docker");
326 }
327
328 #[test]
329 fn find_app_by_bundle_id() {
330 let apps = vec![make_app_info()];
331 let results = find_app("com.docker", &apps);
332 assert_eq!(results.len(), 1);
333 }
334
335 #[test]
336 fn find_app_case_insensitive() {
337 let apps = vec![make_app_info()];
338 let results = find_app("DOCKER", &apps);
339 assert_eq!(results.len(), 1);
340 }
341
342 #[test]
343 fn find_app_no_match() {
344 let apps = vec![make_app_info()];
345 let results = find_app("nonexistent", &apps);
346 assert!(results.is_empty());
347 }
348
349 #[test]
350 fn trace_paths_generated() {
351 let tmp = std::env::temp_dir().join("diskforge_test_uninstall");
353 let _ = fs::remove_dir_all(&tmp);
354
355 let caches = tmp.join("Library/Caches/com.docker.docker");
357 fs::create_dir_all(&caches).unwrap();
358 fs::write(caches.join("data.bin"), vec![0u8; 1024]).unwrap();
359
360 let app_support = tmp.join("Library/Application Support/Docker");
361 fs::create_dir_all(&app_support).unwrap();
362 fs::write(app_support.join("config.json"), "{}").unwrap();
363
364 let prefs = tmp.join("Library/Preferences");
365 fs::create_dir_all(&prefs).unwrap();
366 fs::write(prefs.join("com.docker.docker.plist"), "fake").unwrap();
367
368 let group = tmp.join("Library/Group Containers/group.com.docker.docker");
369 fs::create_dir_all(&group).unwrap();
370 fs::write(group.join("state"), "x").unwrap();
371
372 let saved_state = tmp.join("Library/Saved Application State/com.docker.docker.savedState");
373 fs::create_dir_all(&saved_state).unwrap();
374 fs::write(saved_state.join("window.data"), "w").unwrap();
375
376 let app = AppInfo {
377 name: "Docker".into(),
378 bundle_id: "com.docker.docker".into(),
379 app_path: PathBuf::from("/nonexistent/Docker.app"), };
381
382 let traces = find_app_traces(&app, &tmp.to_string_lossy());
383
384 assert!(
387 traces.len() >= 5,
388 "Expected at least 5 traces, got {}: {:?}",
389 traces.len(),
390 traces
391 .iter()
392 .map(|t| t.path.display().to_string())
393 .collect::<Vec<_>>()
394 );
395
396 assert!(traces.iter().any(|t| {
398 t.path
399 .to_string_lossy()
400 .contains("Caches/com.docker.docker")
401 }));
402 assert!(
404 traces
405 .iter()
406 .any(|t| t.path.to_string_lossy().contains("com.docker.docker.plist"))
407 );
408 assert!(
410 traces
411 .iter()
412 .any(|t| t.path.to_string_lossy().contains("group.com.docker.docker"))
413 );
414
415 fs::remove_dir_all(&tmp).ok();
416 }
417}