diskforge_core/rules/
simulator.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::sizing;
5use crate::types::{Category, CleanableItem, Risk};
6
7pub 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
16fn 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
34fn 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 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 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 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}