Skip to main content

diskforge_core/rules/
simulator.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::sizing;
5use crate::types::{Category, CleanableItem, Risk};
6
7/// Scan for iOS Simulator artifacts (devices + runtimes)
8pub fn scan_simulators(home: &str) -> Vec<CleanableItem> {
9    let mut items = Vec::new();
10    let name_map = load_simulator_names();
11    scan_devices(home, &name_map, &mut items);
12    scan_runtimes(home, &mut items);
13    items
14}
15
16/// Build UUID → display name map via `xcrun simctl list devices`
17fn load_simulator_names() -> HashMap<String, String> {
18    let mut map = HashMap::new();
19    let Ok(output) = std::process::Command::new("xcrun")
20        .args(["simctl", "list", "devices"])
21        .output()
22    else {
23        return map;
24    };
25    let text = String::from_utf8_lossy(&output.stdout);
26    for line in text.lines() {
27        if let Some((name, uuid)) = parse_simctl_line(line) {
28            map.insert(uuid, name);
29        }
30    }
31    map
32}
33
34/// Parse a line like "    iPhone 15 Pro (UUID) (Shutdown)" → (name, uuid)
35fn parse_simctl_line(line: &str) -> Option<(String, String)> {
36    let trimmed = line.trim();
37    let first_open = trimmed.find('(')?;
38    let first_close = trimmed[first_open..].find(')')? + first_open;
39    let candidate = &trimmed[first_open + 1..first_close];
40    // UUID format: 8-4-4-4-12 = 36 chars, 4 dashes
41    if candidate.len() == 36 && candidate.chars().filter(|&c| c == '-').count() == 4 {
42        let name = trimmed[..first_open].trim().to_string();
43        if !name.is_empty() {
44            return Some((name, candidate.to_string()));
45        }
46    }
47    None
48}
49
50fn scan_devices(home: &str, name_map: &HashMap<String, String>, items: &mut Vec<CleanableItem>) {
51    let devices_dir = PathBuf::from(format!("{home}/Library/Developer/CoreSimulator/Devices"));
52    if !devices_dir.exists() {
53        return;
54    }
55    let Ok(entries) = std::fs::read_dir(&devices_dir) else {
56        return;
57    };
58    for entry in entries.flatten() {
59        let Ok(ft) = entry.file_type() else { continue };
60        if !ft.is_dir() {
61            continue;
62        }
63        let path = entry.path();
64        let size = sizing::dir_size(&path);
65        if size < 1024 * 1024 {
66            continue;
67        }
68        let uuid = path
69            .file_name()
70            .map(|n| n.to_string_lossy().to_string())
71            .unwrap_or_default();
72        let description = name_map
73            .get(&uuid)
74            .map(|n| format!("Simulator: {n}"))
75            .unwrap_or_else(|| format!("Simulator: {uuid}"));
76        let last_modified = sizing::dir_last_modified(&path);
77        items.push(CleanableItem {
78            category: Category::Simulator,
79            path,
80            size,
81            risk: Risk::Low,
82            regenerates: false,
83            regeneration_hint: Some("xcrun simctl create to recreate".into()),
84            last_modified,
85            description,
86            cleanup_command: None,
87        });
88    }
89}
90
91fn scan_runtimes(home: &str, items: &mut Vec<CleanableItem>) {
92    // Runtimes live in one of these paths depending on Xcode version
93    let candidates = [
94        format!("{home}/Library/Developer/CoreSimulator/Profiles/Runtimes"),
95        format!("{home}/Library/Developer/CoreSimulator/Cryptex/OS"),
96    ];
97    for dir_str in &candidates {
98        let dir = PathBuf::from(dir_str);
99        if !dir.exists() {
100            continue;
101        }
102        let Ok(entries) = std::fs::read_dir(&dir) else {
103            continue;
104        };
105        for entry in entries.flatten() {
106            let path = entry.path();
107            let size = sizing::dir_size(&path);
108            if size < 100 * 1024 * 1024 {
109                // skip < 100 MB
110                continue;
111            }
112            let name = path
113                .file_stem()
114                .or_else(|| path.file_name())
115                .map(|n| format!("iOS Runtime: {}", n.to_string_lossy()))
116                .unwrap_or_else(|| "iOS Runtime".into());
117            let last_modified = sizing::dir_last_modified(&path);
118            items.push(CleanableItem {
119                category: Category::Simulator,
120                path,
121                size,
122                risk: Risk::Medium,
123                regenerates: false,
124                regeneration_hint: Some("Xcode → Settings → Platforms to reinstall".into()),
125                last_modified,
126                description: name,
127                cleanup_command: None,
128            });
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn parse_device_line() {
139        let line = "    iPhone 15 Pro (A1B2C3D4-E5F6-7890-ABCD-EF1234567890) (Shutdown)";
140        let (name, uuid) = parse_simctl_line(line).unwrap();
141        assert_eq!(name, "iPhone 15 Pro");
142        assert_eq!(uuid, "A1B2C3D4-E5F6-7890-ABCD-EF1234567890");
143    }
144
145    #[test]
146    fn parse_header_line() {
147        assert!(parse_simctl_line("-- iOS 17.2 --").is_none());
148    }
149
150    #[test]
151    fn parse_unavailable_device() {
152        assert!(parse_simctl_line("    iPhone 15 (unavailable)").is_none());
153    }
154
155    #[test]
156    fn parse_empty_line() {
157        assert!(parse_simctl_line("").is_none());
158        assert!(parse_simctl_line("   ").is_none());
159    }
160}