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    items.push(CleanableItem {
284        category: Category::AppCache(app.name.clone()),
285        path: path.to_path_buf(),
286        size,
287        risk,
288        regenerates: false,
289        regeneration_hint: None,
290        last_modified,
291        description,
292        cleanup_command: None,
293    });
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::fs;
300
301    fn make_app_info() -> AppInfo {
302        AppInfo {
303            name: "Docker".into(),
304            bundle_id: "com.docker.docker".into(),
305            app_path: PathBuf::from("/Applications/Docker.app"),
306        }
307    }
308
309    #[test]
310    fn find_app_by_name() {
311        let apps = vec![
312            make_app_info(),
313            AppInfo {
314                name: "Safari".into(),
315                bundle_id: "com.apple.Safari".into(),
316                app_path: PathBuf::from("/Applications/Safari.app"),
317            },
318        ];
319
320        let results = find_app("docker", &apps);
321        assert_eq!(results.len(), 1);
322        assert_eq!(results[0].name, "Docker");
323    }
324
325    #[test]
326    fn find_app_by_bundle_id() {
327        let apps = vec![make_app_info()];
328        let results = find_app("com.docker", &apps);
329        assert_eq!(results.len(), 1);
330    }
331
332    #[test]
333    fn find_app_case_insensitive() {
334        let apps = vec![make_app_info()];
335        let results = find_app("DOCKER", &apps);
336        assert_eq!(results.len(), 1);
337    }
338
339    #[test]
340    fn find_app_no_match() {
341        let apps = vec![make_app_info()];
342        let results = find_app("nonexistent", &apps);
343        assert!(results.is_empty());
344    }
345
346    #[test]
347    fn trace_paths_generated() {
348        // Create a temp structure mimicking ~/Library
349        let tmp = std::env::temp_dir().join("diskforge_test_uninstall");
350        let _ = fs::remove_dir_all(&tmp);
351
352        // Create trace directories
353        let caches = tmp.join("Library/Caches/com.docker.docker");
354        fs::create_dir_all(&caches).unwrap();
355        fs::write(caches.join("data.bin"), vec![0u8; 1024]).unwrap();
356
357        let app_support = tmp.join("Library/Application Support/Docker");
358        fs::create_dir_all(&app_support).unwrap();
359        fs::write(app_support.join("config.json"), "{}").unwrap();
360
361        let prefs = tmp.join("Library/Preferences");
362        fs::create_dir_all(&prefs).unwrap();
363        fs::write(prefs.join("com.docker.docker.plist"), "fake").unwrap();
364
365        let group = tmp.join("Library/Group Containers/group.com.docker.docker");
366        fs::create_dir_all(&group).unwrap();
367        fs::write(group.join("state"), "x").unwrap();
368
369        let saved_state = tmp.join("Library/Saved Application State/com.docker.docker.savedState");
370        fs::create_dir_all(&saved_state).unwrap();
371        fs::write(saved_state.join("window.data"), "w").unwrap();
372
373        let app = AppInfo {
374            name: "Docker".into(),
375            bundle_id: "com.docker.docker".into(),
376            app_path: PathBuf::from("/nonexistent/Docker.app"), // skip .app bundle check
377        };
378
379        let traces = find_app_traces(&app, &tmp.to_string_lossy());
380
381        // Should find: Caches dir, Application Support dir, Preferences plist,
382        // Group Containers dir, Saved Application State dir
383        assert!(
384            traces.len() >= 5,
385            "Expected at least 5 traces, got {}: {:?}",
386            traces.len(),
387            traces
388                .iter()
389                .map(|t| t.path.display().to_string())
390                .collect::<Vec<_>>()
391        );
392
393        // Verify Caches trace exists
394        assert!(traces.iter().any(|t| {
395            t.path
396                .to_string_lossy()
397                .contains("Caches/com.docker.docker")
398        }));
399        // Verify Preferences plist exists
400        assert!(
401            traces
402                .iter()
403                .any(|t| t.path.to_string_lossy().contains("com.docker.docker.plist"))
404        );
405        // Verify Group Containers exists
406        assert!(
407            traces
408                .iter()
409                .any(|t| t.path.to_string_lossy().contains("group.com.docker.docker"))
410        );
411
412        fs::remove_dir_all(&tmp).ok();
413    }
414}