Skip to main content

diskforge_core/rules/
app_uninstall.rs

1use std::path::{Path, PathBuf};
2
3use crate::sizing;
4use crate::types::{Category, CleanableItem, Risk};
5
6/// Information about an installed macOS application
7#[derive(Debug, Clone)]
8pub struct AppInfo {
9    /// Display name (e.g., "Docker")
10    pub name: String,
11    /// CFBundleIdentifier (e.g., "com.docker.docker")
12    pub bundle_id: String,
13    /// Path to the .app bundle (e.g., "/Applications/Docker.app")
14    pub app_path: PathBuf,
15}
16
17/// List all installed applications from /Applications by reading their Info.plist files.
18/// Returns apps sorted by name.
19pub fn list_apps() -> Vec<AppInfo> {
20    list_apps_in("/Applications")
21}
22
23/// List apps in a given directory (useful for testing).
24pub 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
40/// Read Info.plist from an .app bundle and extract name + bundle ID.
41fn 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    // Prefer CFBundleName, fall back to CFBundleDisplayName, then filename
49    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
68/// Find an app by query string. Matches case-insensitively against:
69/// - Display name (substring match)
70/// - Bundle identifier (substring match)
71///
72/// Returns all matching apps.
73pub 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
84/// All ~/Library subdirectory patterns where apps leave traces.
85/// Each entry is (subdirectory relative to ~/Library, is_file_pattern).
86const 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    /// Match directory entries by app name or bundle ID
106    DirMatchNameOrId,
107    /// Match directory entries by bundle ID only
108    DirMatchId,
109    /// Match directory entries whose name ends with the bundle ID
110    DirMatchIdSuffix,
111    /// Match a single .plist file by bundle ID (e.g., com.docker.docker.plist)
112    PlistMatchId,
113    /// Glob for .plist files containing the bundle ID (e.g., com.docker.docker.*.plist)
114    PlistGlobId,
115    /// Glob for non-plist files containing the bundle ID (e.g., com.docker.docker.binarycookies)
116    FileGlobId,
117}
118
119/// Find all traces of an app across ~/Library subdirectories.
120/// Returns CleanableItems sorted by size (largest first).
121pub 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    // Also check the .app bundle itself
153    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
172/// Find directory entries matching app name or bundle ID.
173fn 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
193/// Find directory entries whose name ends with the bundle ID (Group Containers, savedState).
194fn 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        // Match "group.com.docker.docker" or "com.docker.docker.savedState"
205        if entry_lower.contains(&id_lower) {
206            let path = entry.path();
207            add_trace_item(&path, app, items);
208        }
209    }
210}
211
212/// Find a specific .plist file matching the bundle ID.
213fn 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
220/// Glob for .plist files containing the bundle ID (e.g., ByHost/com.docker.docker.*.plist).
221fn 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
236/// Glob for non-plist files containing the bundle ID (e.g., .binarycookies).
237fn 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
251/// Create a CleanableItem from a trace path and add it to the list.
252fn 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    // Determine risk by trace type:
263    // - plist/preferences: None (easily regenerated by the app on next launch)
264    // - everything else (caches, containers, app support dirs): Low
265    // Note: .app bundle itself is handled separately in find_app_traces() as Medium
266    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    // Elevate risk for critical app data (messaging sessions, password vaults, etc.)
284    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        // Create a temp structure mimicking ~/Library
352        let tmp = std::env::temp_dir().join("diskforge_test_uninstall");
353        let _ = fs::remove_dir_all(&tmp);
354
355        // Create trace directories
356        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"), // skip .app bundle check
380        };
381
382        let traces = find_app_traces(&app, &tmp.to_string_lossy());
383
384        // Should find: Caches dir, Application Support dir, Preferences plist,
385        // Group Containers dir, Saved Application State dir
386        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        // Verify Caches trace exists
397        assert!(traces.iter().any(|t| {
398            t.path
399                .to_string_lossy()
400                .contains("Caches/com.docker.docker")
401        }));
402        // Verify Preferences plist exists
403        assert!(
404            traces
405                .iter()
406                .any(|t| t.path.to_string_lossy().contains("com.docker.docker.plist"))
407        );
408        // Verify Group Containers exists
409        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}