smix_simctl/registry.rs
1//! The device registry — deterministic device addressing.
2//!
3//! Records live in the `smix-store` under `.smix/`. A pre-store
4//! `sims.json` sitting beside it is imported on open and then left
5//! alone; smix never writes that file again.
6//!
7//! Every smix device operation targets either an explicit UDID or an
8//! alias recorded in this file. Resolution never consults the live
9//! simulator set: the registry file is the only mapping source, so a
10//! given input always resolves to the same device regardless of what
11//! happens to be booted on the machine.
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use thiserror::Error;
17
18/// Failure variants for registry load / device-ref resolution.
19#[derive(Debug, Error)]
20pub enum RegistryError {
21 /// Registry file could not be read.
22 #[error("cannot read sim registry {path}: {source}")]
23 Io {
24 /// Path that failed to read.
25 path: String,
26 /// Underlying I/O error.
27 source: std::io::Error,
28 },
29 /// Registry file is not valid registry JSON.
30 #[error("malformed sim registry {path}: {detail}")]
31 Malformed {
32 /// Path that failed to parse.
33 path: String,
34 /// Parser-side detail.
35 detail: String,
36 },
37 /// Input is neither a UDID nor a recorded alias.
38 #[error(
39 "unknown device ref {device_ref:?} — pass an explicit UDID or one of \
40 the recorded aliases: {}",
41 known.join(", ")
42 )]
43 UnknownDevice {
44 /// The input that failed to resolve.
45 device_ref: String,
46 /// Alias keys and device names available in the registry.
47 known: Vec<String>,
48 },
49}
50
51/// One registered simulator.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RegisteredSim {
54 /// Human-chosen device name (also usable as an alias).
55 #[serde(rename = "deviceName")]
56 pub device_name: String,
57 /// CoreSimulator UDID.
58 pub udid: String,
59 /// Runtime identifier.
60 pub runtime: String,
61 /// Device type identifier.
62 #[serde(rename = "deviceType")]
63 pub device_type: String,
64 /// Desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`). When set,
65 /// `smix sim boot` enforces it via
66 /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
67 /// sim if the current locale differs. `None` (field absent) =
68 /// honor whatever locale the sim boots with, no enforcement.
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub locale: Option<String>,
71 /// Desired runner port (SmixRunner FlyingFox HTTP port). When set,
72 /// `smix runner up <alias>` binds the runner to this port instead
73 /// of the CLI default 22087. Two sims can then run their own runner
74 /// in parallel without port collision
75 /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
76 /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
77 /// when absent.
78 #[serde(
79 default,
80 rename = "runnerPort",
81 skip_serializing_if = "Option::is_none"
82 )]
83 pub runner_port: Option<u16>,
84}
85
86/// What [`SimRegistry::register`] did with the alias.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RegisterOutcome {
89 /// The alias did not exist; a new row was written.
90 Added,
91 /// The alias existed; its row was replaced.
92 Updated,
93}
94
95/// Loaded view of the registry, keyed by alias.
96#[derive(Debug)]
97pub struct SimRegistry {
98 sims: BTreeMap<String, RegisteredSim>,
99}
100
101/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
102///
103/// UDID-form input is always treated as a deliberate instruction and
104/// never requires registry membership.
105pub fn is_udid(s: &str) -> bool {
106 let bytes = s.as_bytes();
107 if bytes.len() != 36 {
108 return false;
109 }
110 for (i, b) in bytes.iter().enumerate() {
111 match i {
112 8 | 13 | 18 | 23 => {
113 if *b != b'-' {
114 return false;
115 }
116 }
117 _ => {
118 if !b.is_ascii_hexdigit() {
119 return false;
120 }
121 }
122 }
123 }
124 true
125}
126
127/// Resolve a caller-supplied path to the `.smix` directory holding the
128/// store.
129///
130/// Callers pass either form: `SMIX_SIMS_JSON` documents a file, while
131/// discovery yields a directory. Accepting both is also what lets the
132/// pre-store test suite keep exercising the legacy import path
133/// unchanged, instead of being rewritten into agreement with the code
134/// it is supposed to check.
135pub fn store_dir(path: &Path) -> PathBuf {
136 smix_dir(path)
137}
138
139fn smix_dir(path: &Path) -> PathBuf {
140 if path.extension().is_some_and(|e| e == "json") {
141 path.parent().unwrap_or(path).to_path_buf()
142 } else {
143 path.to_path_buf()
144 }
145}
146
147/// Open the store under `path` and fold in any legacy `sims.json`
148/// sitting beside it.
149///
150/// The legacy file is read, never written and never removed: a user who
151/// has to go back to a pre-store smix must still find their registry.
152fn open_store(path: &Path) -> Result<smix_store::Store, RegistryError> {
153 let dir = smix_dir(path);
154 std::fs::create_dir_all(&dir).map_err(|source| RegistryError::Io {
155 path: dir.display().to_string(),
156 source,
157 })?;
158 let store = smix_store::Store::open(&dir).map_err(|e| RegistryError::Io {
159 path: dir.display().to_string(),
160 source: std::io::Error::other(e.to_string()),
161 })?;
162 let legacy = dir.join("sims.json");
163 smix_store::import_legacy_records(&store.sims(), &legacy, "sims").map_err(|e| {
164 RegistryError::Malformed {
165 path: legacy.display().to_string(),
166 detail: e.to_string(),
167 }
168 })?;
169 Ok(store)
170}
171
172impl SimRegistry {
173 /// Write `sim` into the registry under `alias`.
174 ///
175 /// One key, not a whole file. The read-modify-write this replaces
176 /// lost an alias whenever two processes registered at once — each
177 /// read the file, each inserted its own row, and the second write
178 /// erased the first, with no error on either side.
179 pub fn register(
180 path: &Path,
181 alias: &str,
182 sim: RegisteredSim,
183 ) -> Result<RegisterOutcome, RegistryError> {
184 let store = open_store(path)?;
185 let existed = store
186 .sims()
187 .get(alias)
188 .map_err(|e| RegistryError::Malformed {
189 path: path.display().to_string(),
190 detail: e.to_string(),
191 })?
192 .is_some();
193 store
194 .sims()
195 .put_json(alias, &sim)
196 .map_err(|e| RegistryError::Io {
197 path: path.display().to_string(),
198 source: std::io::Error::other(e.to_string()),
199 })?;
200 store.sync().map_err(|e| RegistryError::Io {
201 path: path.display().to_string(),
202 source: std::io::Error::other(e.to_string()),
203 })?;
204 Ok(if existed {
205 RegisterOutcome::Updated
206 } else {
207 RegisterOutcome::Added
208 })
209 }
210
211 /// Read every registered sim.
212 ///
213 /// `path` may be the `.smix` directory or a legacy `sims.json`
214 /// inside it; both land on the same store.
215 pub fn load(path: &Path) -> Result<Self, RegistryError> {
216 let store = open_store(path)?;
217 let mut sims = BTreeMap::new();
218 for alias in store.sims().list() {
219 let sim: RegisteredSim = store
220 .sims()
221 .get_json(&alias)
222 .map_err(|e| RegistryError::Malformed {
223 path: path.display().to_string(),
224 detail: e.to_string(),
225 })?
226 .ok_or_else(|| RegistryError::Malformed {
227 path: path.display().to_string(),
228 detail: format!("`{alias}` vanished between listing and reading"),
229 })?;
230 sims.insert(alias, sim);
231 }
232 Ok(Self { sims })
233 }
234
235 /// Walk up from `start` looking for a `.smix` that holds a
236 /// registry — either the store or a legacy `sims.json`.
237 pub fn discover(start: &Path) -> Option<PathBuf> {
238 let mut dir = Some(start);
239 while let Some(d) = dir {
240 let smix = d.join(".smix");
241 if smix.join("sims.json").is_file() || smix.join("kv").is_dir() {
242 return Some(smix);
243 }
244 dir = d.parent();
245 }
246 None
247 }
248
249 /// Resolve a device ref to a UDID.
250 ///
251 /// An explicit UDID passes through (normalized to uppercase) whether
252 /// or not it is registered — UDID-form input is always a deliberate
253 /// instruction. Otherwise the ref must match an alias key or a
254 /// `deviceName` exactly; anything else errors listing what exists.
255 pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
256 if is_udid(device_ref) {
257 return Ok(device_ref.to_ascii_uppercase());
258 }
259 if let Some(sim) = self.sims.get(device_ref) {
260 return Ok(sim.udid.to_ascii_uppercase());
261 }
262 if let Some(sim) = self.sims.values().find(|s| s.device_name == device_ref) {
263 return Ok(sim.udid.to_ascii_uppercase());
264 }
265 let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
266 for (alias, sim) in &self.sims {
267 known.push(alias.clone());
268 known.push(sim.device_name.clone());
269 }
270 Err(RegistryError::UnknownDevice {
271 device_ref: device_ref.to_string(),
272 known,
273 })
274 }
275
276 /// All registered sims, keyed by alias.
277 pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
278 &self.sims
279 }
280
281 /// Look up a [`RegisteredSim`] by alias key, device name, or UDID.
282 /// Returns `None` if no entry matches any of the three. Mirrors
283 /// [`Self::resolve`]'s match precedence so cli callers can fetch
284 /// the full spec (e.g. `locale` field) after they already resolved
285 /// the UDID.
286 pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
287 if let Some(sim) = self.sims.get(device_ref) {
288 return Some(sim);
289 }
290 self.sims
291 .values()
292 .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
293 }
294}