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/// What kind of device a registry entry names.
52///
53/// The distinction exists for one reason: what smix is allowed to do to
54/// it. A simulator can be erased and rebuilt in a minute; a phone in
55/// somebody's pocket cannot. §9#1 hangs the destructive-action guard off
56/// this field.
57///
58/// Defaults to `Simulator` when the field is absent, and that direction
59/// is deliberate. Every registry written before this field existed was
60/// written by a simulator; reading those as physical would lock a working
61/// setup behind an opt-in nobody asked for. Guessing "simulator" costs at
62/// most one missing gate on a device that never needed it — guessing
63/// "physical" breaks people who did nothing wrong.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub enum DeviceKind {
67 /// iOS Simulator.
68 #[default]
69 Simulator,
70 /// Android emulator.
71 Emulator,
72 /// A physical iPhone or iPad.
73 PhysicalIos,
74 /// A physical Android device.
75 PhysicalAndroid,
76}
77
78impl DeviceKind {
79 /// Is this a device somebody might be carrying around?
80 #[must_use]
81 pub fn is_physical(self) -> bool {
82 matches!(self, DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid)
83 }
84}
85
86/// One registered simulator.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct RegisteredSim {
89 /// Human-chosen device name (also usable as an alias).
90 #[serde(rename = "deviceName")]
91 pub device_name: String,
92 /// What kind of device this is. Absent in registries written before
93 /// physical devices were addressable — see [`DeviceKind`] for why
94 /// that reads as `Simulator`.
95 #[serde(default)]
96 pub kind: DeviceKind,
97 /// Whether destructive actions have been allowed on this device.
98 ///
99 /// Only consulted for physical devices; a simulator is never gated.
100 /// Recorded once here rather than confirmed per command, because a
101 /// confirmation that must be typed every time ends up in a script,
102 /// which is the same as not having one.
103 #[serde(default, rename = "destructiveOptIn")]
104 pub destructive_opt_in: bool,
105 /// CoreSimulator UDID.
106 pub udid: String,
107 /// Runtime identifier.
108 pub runtime: String,
109 /// Device type identifier.
110 #[serde(rename = "deviceType")]
111 pub device_type: String,
112 /// Desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`). When set,
113 /// `smix sim boot` enforces it via
114 /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
115 /// sim if the current locale differs. `None` (field absent) =
116 /// honor whatever locale the sim boots with, no enforcement.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub locale: Option<String>,
119 /// Desired runner port (SmixRunner FlyingFox HTTP port). When set,
120 /// `smix runner up <alias>` binds the runner to this port instead
121 /// of the CLI default 22087. Two sims can then run their own runner
122 /// in parallel without port collision
123 /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
124 /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
125 /// when absent.
126 #[serde(
127 default,
128 rename = "runnerPort",
129 skip_serializing_if = "Option::is_none"
130 )]
131 pub runner_port: Option<u16>,
132}
133
134/// What [`SimRegistry::register`] did with the alias.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum RegisterOutcome {
137 /// The alias did not exist; a new row was written.
138 Added,
139 /// The alias existed; its row was replaced.
140 Updated,
141}
142
143/// Loaded view of the registry, keyed by alias.
144#[derive(Debug, Default, Clone)]
145pub struct SimRegistry {
146 sims: BTreeMap<String, RegisteredSim>,
147}
148
149/// What a migration did, per alias.
150///
151/// Reported rather than summarised as a count. A migration is a one-way
152/// door and the thing somebody needs afterwards is the ability to look
153/// up one device by name and see what happened to it.
154#[derive(Debug, Default, Clone)]
155pub struct MigrationReport {
156 /// Aliases the destination did not have.
157 pub added: Vec<String>,
158 /// Aliases that were already there, pointing at the same device.
159 pub unchanged: Vec<String>,
160 /// Aliases that were already there and whose record changed —
161 /// which, under [`SimRegistry::merge`], can only mean consent
162 /// narrowed.
163 pub narrowed: Vec<String>,
164 /// Sources that opened and held nothing.
165 pub empty: Vec<PathBuf>,
166 /// Sources that would not open, and why.
167 pub unreadable: Vec<(PathBuf, String)>,
168}
169
170/// Every registry this machine can read, folded into one.
171#[derive(Debug, Default, Clone)]
172pub struct MergedRegistry {
173 /// The merged view. Which book a device came from is not visible
174 /// here, deliberately: a device is a device.
175 pub registry: SimRegistry,
176 /// Aliases a checkout is the only holder of, and which checkout.
177 ///
178 /// Not an error — those devices work. It is the one thing a caller
179 /// has to be able to say out loud, because a record only one tree
180 /// holds is a record the next tree cannot act on, and the whole
181 /// point of moving these was that "check who owns this runner" can
182 /// be carried out from anywhere.
183 pub unmigrated: BTreeMap<String, PathBuf>,
184}
185
186/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
187///
188/// Answers the shape question only. It used to answer more than that —
189/// UDID-form input was treated as a deliberate instruction that skipped
190/// the registry entirely, and the CLI still short-circuits alias lookup
191/// on it. What changed on 2026-08-06 is that skipping the registry no
192/// longer means skipping every check: a raw identifier now has to be one
193/// the platform itself claims, because the shape alone stopped being
194/// evidence the moment a `devicectl` path appeared that reaches phones
195/// whose CoreDevice UUIDs wear exactly this form.
196pub fn is_udid(s: &str) -> bool {
197 let bytes = s.as_bytes();
198 if bytes.len() != 36 {
199 return false;
200 }
201 for (i, b) in bytes.iter().enumerate() {
202 match i {
203 8 | 13 | 18 | 23 => {
204 if *b != b'-' {
205 return false;
206 }
207 }
208 _ => {
209 if !b.is_ascii_hexdigit() {
210 return false;
211 }
212 }
213 }
214 }
215 true
216}
217
218/// Why an identifier does not fit the kind it was registered under.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct IdentifierMismatch {
221 /// The identifier as given.
222 pub given: String,
223 /// What that kind's identifiers look like.
224 pub expected: &'static str,
225}
226
227impl std::fmt::Display for IdentifierMismatch {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 write!(
230 f,
231 "{:?} is not how that kind of device is identified — {}",
232 self.given, self.expected
233 )
234 }
235}
236
237/// Does this identifier fit the kind it is being registered as?
238///
239/// Shape only; whether the device exists is a separate question, asked
240/// against a different catalogue per kind, and asked by the caller.
241///
242/// The three answers differ because the world does:
243///
244/// * A **simulator** is a directory on this Mac with a CoreSimulator
245/// UDID, and `simctl` can list every one of them.
246/// * An **emulator** is named by `adb`, which calls them `emulator-<port>`
247/// and will list them too.
248/// * A **phone** has no catalogue at all. Nothing on this machine can
249/// enumerate the world's devices, so its identifier is taken as given —
250/// which is exactly why registering one has to be a deliberate act
251/// rather than a lookup. That is not a hole in the check; it is the
252/// reason the check exists.
253///
254/// # Errors
255///
256/// Returns what that kind's identifiers look like, so the message can
257/// say which of the three worlds the caller landed in the wrong one of.
258pub fn identifier_fits(kind: DeviceKind, id: &str) -> Result<(), IdentifierMismatch> {
259 let ok = match kind {
260 DeviceKind::Simulator => is_udid(id),
261 DeviceKind::Emulator => is_emulator_serial(id),
262 // No catalogue exists to check against.
263 DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => !id.trim().is_empty(),
264 };
265 if ok {
266 return Ok(());
267 }
268 Err(IdentifierMismatch {
269 given: id.to_string(),
270 expected: match kind {
271 DeviceKind::Simulator => {
272 "a simulator has a CoreSimulator UDID (8-4-4-4-12 hex); find it with `smix sim list`"
273 }
274 DeviceKind::Emulator => {
275 "adb names an emulator `emulator-<port>`, e.g. emulator-5554; find it with `adb devices`"
276 }
277 DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => {
278 "a physical device needs a non-empty identifier: a UDID for iOS, an adb serial for Android"
279 }
280 },
281 })
282}
283
284/// Put an identifier in the form its platform matches on.
285///
286/// Normalise what has a normal form; preserve what is matched verbatim.
287///
288/// Apple's identifiers are hex and canonically upper-case, and this is
289/// not a style preference: `devicectl` was measured on 2026-08-06 to
290/// reject the lower-case spelling of a UDID it accepts in upper-case
291/// (`ERROR: The specified device was not found`). Upper-casing therefore
292/// rescues a typed-in identifier rather than mangling it.
293///
294/// `adb` matches serials byte for byte, so there is nothing to rescue and
295/// everything to break: `EMULATOR-5554` is not a device, and neither is a
296/// vendor serial that came with lower-case letters in it.
297///
298/// Done once, here, where the kind is known — never again downstream. A
299/// value normalised twice by two different rules is how `sim resolve` came
300/// to hand out `EMULATOR-5554`.
301#[must_use]
302pub fn canonical_identifier(kind: DeviceKind, id: &str) -> String {
303 match kind {
304 DeviceKind::Simulator | DeviceKind::PhysicalIos => id.to_ascii_uppercase(),
305 DeviceKind::Emulator | DeviceKind::PhysicalAndroid => id.to_string(),
306 }
307}
308
309/// Whether `s` is an adb emulator serial.
310///
311/// `adb` is the one naming these, so this recognises rather than guesses:
312/// an emulator is `emulator-<port>`, and a physical device answers with a
313/// hardware serial that never takes that form. Case matters — `adb`
314/// matches serials verbatim and `EMULATOR-5554` is not a device.
315#[must_use]
316pub fn is_emulator_serial(s: &str) -> bool {
317 s.strip_prefix("emulator-")
318 .is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
319}
320
321/// Resolve a caller-supplied path to the `.smix` directory holding the
322/// store.
323///
324/// Callers pass either form: `SMIX_SIMS_JSON` documents a file, while
325/// discovery yields a directory. Accepting both is also what lets the
326/// pre-store test suite keep exercising the legacy import path
327/// unchanged, instead of being rewritten into agreement with the code
328/// it is supposed to check.
329pub fn store_dir(path: &Path) -> PathBuf {
330 smix_dir(path)
331}
332
333fn smix_dir(path: &Path) -> PathBuf {
334 if path.extension().is_some_and(|e| e == "json") {
335 path.parent().unwrap_or(path).to_path_buf()
336 } else {
337 path.to_path_buf()
338 }
339}
340
341/// Open the store under `path` and fold in any legacy `sims.json`
342/// sitting beside it.
343///
344/// The legacy file is read, never written and never removed: a user who
345/// has to go back to a pre-store smix must still find their registry.
346fn open_store(path: &Path) -> Result<smix_store::Store, RegistryError> {
347 let dir = smix_dir(path);
348 std::fs::create_dir_all(&dir).map_err(|source| RegistryError::Io {
349 path: dir.display().to_string(),
350 source,
351 })?;
352 let store = smix_store::Store::open(&dir).map_err(|e| RegistryError::Io {
353 path: dir.display().to_string(),
354 source: std::io::Error::other(e.to_string()),
355 })?;
356 let legacy = dir.join("sims.json");
357 smix_store::import_legacy_records(&store.sims(), &legacy, "sims").map_err(|e| {
358 RegistryError::Malformed {
359 path: legacy.display().to_string(),
360 detail: e.to_string(),
361 }
362 })?;
363 Ok(store)
364}
365
366impl SimRegistry {
367 /// Write `sim` into the registry under `alias`.
368 ///
369 /// One key, not a whole file. The read-modify-write this replaces
370 /// lost an alias whenever two processes registered at once — each
371 /// read the file, each inserted its own row, and the second write
372 /// erased the first, with no error on either side.
373 pub fn register(
374 path: &Path,
375 alias: &str,
376 sim: RegisteredSim,
377 ) -> Result<RegisterOutcome, RegistryError> {
378 let store = open_store(path)?;
379 let existed = store
380 .sims()
381 .get(alias)
382 .map_err(|e| RegistryError::Malformed {
383 path: path.display().to_string(),
384 detail: e.to_string(),
385 })?
386 .is_some();
387 store
388 .sims()
389 .put_json(alias, &sim)
390 .map_err(|e| RegistryError::Io {
391 path: path.display().to_string(),
392 source: std::io::Error::other(e.to_string()),
393 })?;
394 store.sync().map_err(|e| RegistryError::Io {
395 path: path.display().to_string(),
396 source: std::io::Error::other(e.to_string()),
397 })?;
398 Ok(if existed {
399 RegisterOutcome::Updated
400 } else {
401 RegisterOutcome::Added
402 })
403 }
404
405 /// Record which alias a project defaults to, keyed by the project's
406 /// path. The value is the alias string only — a pointer into the
407 /// registered devices, never a device fact. What the alias resolves
408 /// to (UDID, kind, opt-in) stays in the machine registry; this says
409 /// only "this project drives that one", which is a project's to
410 /// decide and safe to keep per-project (§9 #9: the pointer may live
411 /// with the project, the facts may not).
412 ///
413 /// # Errors
414 /// [`RegistryError::Io`] if the store cannot be opened or written.
415 pub fn set_project_alias(
416 path: &Path,
417 project_key: &str,
418 alias: &str,
419 ) -> Result<(), RegistryError> {
420 let store = open_store(path)?;
421 store
422 .project_devices()
423 .put(project_key, alias.as_bytes())
424 .map_err(|e| RegistryError::Io {
425 path: path.display().to_string(),
426 source: std::io::Error::other(e.to_string()),
427 })?;
428 store.sync().map_err(|e| RegistryError::Io {
429 path: path.display().to_string(),
430 source: std::io::Error::other(e.to_string()),
431 })
432 }
433
434 /// The alias a project defaults to, or `None` if it has never set
435 /// one. Read-only counterpart of [`Self::set_project_alias`].
436 ///
437 /// # Errors
438 /// [`RegistryError::Io`] if the store cannot be opened or read.
439 pub fn project_alias(path: &Path, project_key: &str) -> Result<Option<String>, RegistryError> {
440 let store = open_store(path)?;
441 let raw = store
442 .project_devices()
443 .get(project_key)
444 .map_err(|e| RegistryError::Io {
445 path: path.display().to_string(),
446 source: std::io::Error::other(e.to_string()),
447 })?;
448 Ok(raw.map(|bytes| String::from_utf8_lossy(&bytes).into_owned()))
449 }
450
451 /// Remove one alias from the registry at `path`.
452 ///
453 /// The other half of [`Self::register`], and absent until the
454 /// records moved to machine scope made its absence permanent: a
455 /// device registered by mistake — a test that wrote to the real
456 /// book, an alias for a phone somebody no longer has — could be
457 /// added and never taken back. A registry that only grows stops
458 /// describing the machine.
459 ///
460 /// Removes the name, not the device. Another alias for the same
461 /// device keeps working, which is why this takes an alias key
462 /// rather than a device ref: "forget this device" and "forget this
463 /// name for it" are different requests, and only the second one is
464 /// unambiguous.
465 ///
466 /// # Errors
467 ///
468 /// [`RegistryError::UnknownDevice`] when no such alias exists.
469 /// Silently succeeding would let a typo read as a removal.
470 pub fn unregister(path: &Path, alias: &str) -> Result<RegisteredSim, RegistryError> {
471 let reg = Self::load(path)?;
472 let Some(sim) = reg.sims.get(alias).cloned() else {
473 return Err(RegistryError::UnknownDevice {
474 device_ref: alias.to_string(),
475 known: reg.sims.keys().cloned().collect(),
476 });
477 };
478 let store = open_store(path)?;
479 store.sims().delete(alias).map_err(|e| RegistryError::Io {
480 path: path.display().to_string(),
481 source: std::io::Error::other(e.to_string()),
482 })?;
483 store.sync().map_err(|e| RegistryError::Io {
484 path: path.display().to_string(),
485 source: std::io::Error::other(e.to_string()),
486 })?;
487 Ok(sim)
488 }
489
490 /// Allow destructive actions on one registered device, once.
491 ///
492 /// Goes through [`Self::register`] rather than rewriting the file,
493 /// for the reason that function documents: a read-modify-write of the
494 /// whole registry loses a concurrent registration silently. One key
495 /// in, one key out.
496 ///
497 /// Returns the alias it was recorded against and whether it was
498 /// already allowed — the caller can then say "already allowed"
499 /// instead of implying something changed.
500 ///
501 /// # Errors
502 ///
503 /// [`RegistryError::UnknownDevice`] when nothing matches the ref. The
504 /// opt-in is per device, so there is nothing to record it against —
505 /// and silently creating an entry would mean allowing destruction on
506 /// a device nobody registered.
507 pub fn allow_destructive(
508 path: &Path,
509 device_ref: &str,
510 ) -> Result<(String, bool), RegistryError> {
511 let reg = Self::load(path)?;
512 let Some((alias, sim)) = reg
513 .sims()
514 .iter()
515 .find(|(alias, sim)| {
516 alias.as_str() == device_ref
517 || sim.device_name == device_ref
518 || sim.udid.eq_ignore_ascii_case(device_ref)
519 })
520 .map(|(a, s)| (a.clone(), s.clone()))
521 else {
522 let mut known: Vec<String> = Vec::new();
523 for (alias, sim) in reg.sims() {
524 known.push(alias.clone());
525 known.push(sim.device_name.clone());
526 }
527 return Err(RegistryError::UnknownDevice {
528 device_ref: device_ref.to_string(),
529 known,
530 });
531 };
532 if sim.destructive_opt_in {
533 return Ok((alias, true));
534 }
535 let updated = RegisteredSim {
536 destructive_opt_in: true,
537 ..sim
538 };
539 Self::register(path, &alias, updated)?;
540 Ok((alias, false))
541 }
542
543 /// Read every registered sim.
544 ///
545 /// `path` may be the `.smix` directory or a legacy `sims.json`
546 /// inside it; both land on the same store.
547 pub fn load(path: &Path) -> Result<Self, RegistryError> {
548 let store = open_store(path)?;
549 let mut sims = BTreeMap::new();
550 for alias in store.sims().list() {
551 let sim: RegisteredSim = store
552 .sims()
553 .get_json(&alias)
554 .map_err(|e| RegistryError::Malformed {
555 path: path.display().to_string(),
556 detail: e.to_string(),
557 })?
558 .ok_or_else(|| RegistryError::Malformed {
559 path: path.display().to_string(),
560 detail: format!("`{alias}` vanished between listing and reading"),
561 })?;
562 sims.insert(alias, sim);
563 }
564 Ok(Self { sims })
565 }
566
567 /// Where device facts live: this machine, not this checkout.
568 ///
569 /// A simulator is an operating-system object. Its UDID, its runtime
570 /// version, whether it is booted and who booted it do not change
571 /// when you `cd` — so storing them per checkout means four trees on
572 /// one machine each hold their own answer, which is what they do
573 /// today. On 2026-08-11 two of them held a lease on the same
574 /// simulators, and a runner on port 22087 was simultaneously on the
575 /// books and invisible: the rule says check the owner before
576 /// touching a runner, and the checkout doing the checking was not
577 /// the one holding the record.
578 ///
579 /// `$XDG_DATA_HOME/smix` or `~/.local/share/smix`, which is where
580 /// the runner tree and its version stamp already live — machine
581 /// scope is not a new idea here, it was just not used for this.
582 ///
583 /// `None` when neither variable is set, which is the same condition
584 /// under which the runner tree has no home either; the caller falls
585 /// back to the checkout and says so.
586 pub fn machine_dir() -> Option<PathBuf> {
587 smix_lease::store::machine_root().map(|r| r.join("devices"))
588 }
589
590 /// Every registry a read may draw on, in precedence order.
591 ///
592 /// `SMIX_SIMS_JSON` names one registry and means exactly that one:
593 /// it is how a test works against a book of its own, and a
594 /// machine-level fallback under it would let the real one leak in.
595 ///
596 /// Otherwise the machine registry first, then whatever checkout is
597 /// underfoot. The checkout entry keeps books written before this
598 /// move working until `smix sim migrate` folds them in; nothing is
599 /// ever written back to it.
600 pub fn read_paths(start: &Path) -> Vec<PathBuf> {
601 if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
602 return vec![PathBuf::from(p)];
603 }
604 let mut paths = Vec::new();
605 if let Some(m) = Self::machine_dir() {
606 paths.push(m);
607 }
608 if let Some(c) = Self::discover(start) {
609 paths.push(c);
610 }
611 paths
612 }
613
614 /// Read every registry that applies and fold them into one.
615 ///
616 /// A source that will not open is skipped rather than fatal: one
617 /// corrupt book must not strand the devices recorded in the others.
618 /// Which is why this returns a value and not a `Result` — there is
619 /// no failure here other than "nothing was readable anywhere", and
620 /// that is an empty registry, which reads the same as a machine
621 /// where nothing has been registered yet.
622 pub fn open_all(start: &Path) -> MergedRegistry {
623 let paths = Self::read_paths(start);
624 // Under `SMIX_SIMS_JSON` there is no machine/checkout split to
625 // report: the caller named the registry, and calling their own
626 // choice "unmigrated" would be advice to move a book they put
627 // where they wanted it.
628 let machine = if std::env::var_os("SMIX_SIMS_JSON").is_some() {
629 None
630 } else {
631 Self::machine_dir()
632 };
633 let mut loaded: Vec<Self> = Vec::new();
634 let mut unmigrated: BTreeMap<String, PathBuf> = BTreeMap::new();
635 let mut on_machine: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
636 for path in &paths {
637 let Ok(reg) = Self::load(path) else {
638 continue;
639 };
640 if machine.as_deref() == Some(path.as_path()) {
641 on_machine.extend(reg.sims.values().map(|s| s.udid.to_ascii_uppercase()));
642 } else if machine.is_some() {
643 for (alias, sim) in ®.sims {
644 if !on_machine.contains(&sim.udid.to_ascii_uppercase()) {
645 unmigrated.insert(alias.clone(), path.clone());
646 }
647 }
648 }
649 loaded.push(reg);
650 }
651 MergedRegistry {
652 registry: Self::merge(loaded),
653 unmigrated,
654 }
655 }
656
657 /// Walk up from `start` looking for a `.smix` that holds a
658 /// registry — either the store or a legacy `sims.json`.
659 pub fn discover(start: &Path) -> Option<PathBuf> {
660 let mut dir = Some(start);
661 while let Some(d) = dir {
662 let smix = d.join(".smix");
663 if smix.join("sims.json").is_file() || smix.join("kv").is_dir() {
664 return Some(smix);
665 }
666 dir = d.parent();
667 }
668 None
669 }
670
671 /// Resolve a device ref to the identifier its platform is addressed by.
672 ///
673 /// CoreSimulator-form input passes through whether or not it is
674 /// registered. Otherwise the ref must match an alias key, a
675 /// `deviceName`, or the registered identifier itself.
676 ///
677 /// That last one was missing until 2026-08-06, and [`Self::lookup`]
678 /// had it — so the two disagreed about whether a device's own
679 /// identifier names it. A real phone found the disagreement: an iOS
680 /// device UDID is 25 characters, not CoreSimulator's 36, so it fell
681 /// past the short-circuit into a search that never looked at the one
682 /// field it matched. `smix runner forward 00008120-…` answered
683 /// "unknown device ref" about a device that was registered right
684 /// there in the file it was reading.
685 pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
686 if is_udid(device_ref) {
687 return Ok(device_ref.to_ascii_uppercase());
688 }
689 // Stored verbatim, returned verbatim. The value was already put
690 // in its platform's form at registration by
691 // [`canonical_identifier`]; upper-casing it a second time here is
692 // what turned a registered `emulator-5554` into the
693 // `EMULATOR-5554` that adb does not answer to.
694 if let Some(sim) = self.sims.get(device_ref) {
695 return Ok(sim.udid.clone());
696 }
697 if let Some(sim) = self
698 .sims
699 .values()
700 .find(|s| s.device_name == device_ref || s.udid.eq_ignore_ascii_case(device_ref))
701 {
702 return Ok(sim.udid.clone());
703 }
704 // Deduplicated: an alias and a device name are commonly the same
705 // word, and "one of the recorded aliases: phone, phone" reads as
706 // a bug in the tool rather than a list of choices.
707 let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
708 for (alias, sim) in &self.sims {
709 for name in [alias, &sim.device_name] {
710 if !known.iter().any(|k| k == name) {
711 known.push(name.clone());
712 }
713 }
714 }
715 Err(RegistryError::UnknownDevice {
716 device_ref: device_ref.to_string(),
717 known,
718 })
719 }
720
721 /// All registered sims, keyed by alias.
722 /// Fold several registries into one, losing nothing.
723 ///
724 /// Four checkouts on this machine each keep their own, and merging
725 /// them is a one-way door: whatever this drops, the tree that was
726 /// relying on it stops working, and nobody can tell which of the
727 /// four to look in. So the rules are chosen to be safe, not tidy.
728 ///
729 /// - **Two aliases for one device: keep both.** An alias is how
730 /// somebody types a device's name. Dropping one breaks whatever
731 /// script used it; a duplicate is noise.
732 /// - **Conflicting destructive consent: take the stricter.**
733 /// Consent is a per-device authorisation (§9 #1), and merging two
734 /// books is not a moment to widen it. Granting it again is one
735 /// command; un-wiping a phone is not a command at all.
736 /// - **Same alias, different devices: keep both**, the later one
737 /// under a suffixed alias. Silently overwriting means one tree's
738 /// alias stops resolving with no way to know which.
739 ///
740 /// Order-independent for the facts that matter: four checkouts have
741 /// no natural order, and a merge that depends on read order changes
742 /// when somebody renames a directory.
743 pub fn merge(sources: impl IntoIterator<Item = Self>) -> Self {
744 let mut out: BTreeMap<String, RegisteredSim> = BTreeMap::new();
745 // Sorted, so the result cannot depend on which tree was read
746 // first.
747 let mut incoming: Vec<(String, RegisteredSim)> = sources
748 .into_iter()
749 .flat_map(|r| r.sims.into_iter())
750 .collect();
751 incoming.sort_by(|a, b| (&a.0, &a.1.udid).cmp(&(&b.0, &b.1.udid)));
752
753 for (alias, sim) in incoming {
754 match out.get_mut(&alias) {
755 None => {
756 out.insert(alias, sim);
757 }
758 Some(existing) if existing.udid == sim.udid => {
759 // Same device under the same name: the only thing
760 // that can differ is consent, and it narrows.
761 existing.destructive_opt_in =
762 existing.destructive_opt_in && sim.destructive_opt_in;
763 }
764 Some(_) => {
765 // Same name, different device. Both survive; the
766 // second takes a suffix derived from its UDID so the
767 // name is stable across merges rather than depending
768 // on how many collisions came before it.
769 let short = sim.udid.chars().take(8).collect::<String>().to_lowercase();
770 out.insert(format!("{alias}-{short}"), sim);
771 }
772 }
773 }
774 Self { sims: out }
775 }
776
777 /// Fold `sources` into the registry at `into`, keeping everything.
778 ///
779 /// The merge rules are [`Self::merge`]'s; this applies them to books
780 /// on disk. What it adds to them is a promise about the sources:
781 /// they are read and left alone. Somebody who has to go back to a
782 /// smix from before device records became machine-scoped must still
783 /// find their registry where they left it — and a migration that
784 /// deletes what it has just copied has no way to be run twice by
785 /// somebody who is not sure whether it worked.
786 ///
787 /// Writes go through [`Self::register`], one key at a time, for the
788 /// reason that function documents: a whole-file rewrite loses a
789 /// concurrent registration without saying so.
790 pub fn migrate(into: &Path, sources: &[PathBuf]) -> Result<MigrationReport, RegistryError> {
791 Self::migrate_inner(into, sources, true)
792 }
793
794 /// What [`Self::migrate`] would do, having done nothing.
795 ///
796 /// The same function with the write skipped, rather than a second
797 /// one that works the answer out again — a rehearsal with its own
798 /// copy of the rules reports on its copy.
799 ///
800 /// # Errors
801 ///
802 /// As [`Self::migrate`], minus the ones only a write can raise.
803 pub fn migrate_dry_run(
804 into: &Path,
805 sources: &[PathBuf],
806 ) -> Result<MigrationReport, RegistryError> {
807 Self::migrate_inner(into, sources, false)
808 }
809
810 fn migrate_inner(
811 into: &Path,
812 sources: &[PathBuf],
813 commit: bool,
814 ) -> Result<MigrationReport, RegistryError> {
815 let mut report = MigrationReport::default();
816 let mut books = vec![Self::load(into)?];
817 let before: BTreeMap<String, String> = books[0]
818 .sims
819 .iter()
820 .map(|(a, s)| (a.clone(), s.udid.clone()))
821 .collect();
822
823 for src in sources {
824 match Self::load(src) {
825 Ok(reg) if reg.sims.is_empty() => report.empty.push(src.clone()),
826 Ok(reg) => books.push(reg),
827 // Named, not fatal. One book nobody can open must not
828 // strand the devices recorded in the others — and the
829 // path is what somebody needs in order to go look.
830 Err(e) => report.unreadable.push((src.clone(), e.to_string())),
831 }
832 }
833
834 let merged = Self::merge(books);
835 for (alias, sim) in &merged.sims {
836 match before.get(alias) {
837 Some(udid) if udid == &sim.udid => report.unchanged.push(alias.clone()),
838 Some(_) => report.narrowed.push(alias.clone()),
839 None => report.added.push(alias.clone()),
840 }
841 if commit {
842 Self::register(into, alias, sim.clone())?;
843 }
844 }
845 Ok(report)
846 }
847
848 /// Every alias and what it points at.
849 pub fn all(&self) -> impl Iterator<Item = (&str, &RegisteredSim)> {
850 self.sims.iter().map(|(k, v)| (k.as_str(), v))
851 }
852
853 /// Add or replace one entry. For merging and for tests; the
854 /// registering path goes through `register`, which also writes.
855 pub fn insert(&mut self, alias: impl Into<String>, sim: RegisteredSim) {
856 self.sims.insert(alias.into(), sim);
857 }
858
859 /// Every registered sim, keyed by alias.
860 pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
861 &self.sims
862 }
863
864 /// Look up a [`RegisteredSim`] by alias key, device name, or UDID.
865 /// Returns `None` if no entry matches any of the three. Mirrors
866 /// [`Self::resolve`]'s match precedence so cli callers can fetch
867 /// the full spec (e.g. `locale` field) after they already resolved
868 /// the UDID.
869 pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
870 if let Some(sim) = self.sims.get(device_ref) {
871 return Some(sim);
872 }
873 self.sims
874 .values()
875 .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
876 }
877}
878
879#[cfg(test)]
880mod kind_tests {
881 use super::*;
882
883 const UDID: &str = "47ACEAE5-36BA-4C62-811B-F09B397910D7";
884
885 #[test]
886 fn each_virtual_kind_takes_its_own_platforms_identifiers() {
887 assert!(identifier_fits(DeviceKind::Simulator, UDID).is_ok());
888 assert!(identifier_fits(DeviceKind::Emulator, "emulator-5554").is_ok());
889 // And not each other's. A UDID registered as an emulator would
890 // be an alias for something adb can never be handed.
891 assert!(identifier_fits(DeviceKind::Simulator, "emulator-5554").is_err());
892 assert!(identifier_fits(DeviceKind::Emulator, UDID).is_err());
893 }
894
895 #[test]
896 fn a_physical_identifier_is_taken_as_given() {
897 // Nothing on this machine can enumerate the world's phones, so
898 // there is no catalogue to check against — which is precisely
899 // why registering one is a deliberate act. Both spellings are
900 // legitimate: a UDID for iOS, an adb serial for Android.
901 assert!(identifier_fits(DeviceKind::PhysicalIos, "00008120-001410C11A42201E").is_ok());
902 assert!(identifier_fits(DeviceKind::PhysicalAndroid, "R5CT52DF07D").is_ok());
903 // Empty is still nothing.
904 assert!(identifier_fits(DeviceKind::PhysicalIos, " ").is_err());
905 }
906
907 #[test]
908 fn an_emulator_serial_is_recognised_not_guessed() {
909 assert!(is_emulator_serial("emulator-5554"));
910 assert!(!is_emulator_serial("emulator-"));
911 assert!(!is_emulator_serial("emulator-abcd"));
912 // Case matters: adb matches serials verbatim, and this is not a
913 // device. The UDID path upper-cases; this one must not.
914 assert!(!is_emulator_serial("EMULATOR-5554"));
915 assert!(!is_emulator_serial("R5CT52DF07D"));
916 }
917
918 #[test]
919 fn apple_identifiers_are_normalised_and_adb_serials_are_not() {
920 // Measured, not assumed: `devicectl` rejects the lower-case
921 // spelling of a UDID it accepts in upper-case, so upper-casing
922 // an Apple identifier rescues it. `adb` matches byte for byte,
923 // so the same move would break it.
924 assert_eq!(
925 canonical_identifier(
926 DeviceKind::Simulator,
927 "47aceae5-36ba-4c62-811b-f09b397910d7"
928 ),
929 "47ACEAE5-36BA-4C62-811B-F09B397910D7"
930 );
931 assert_eq!(
932 canonical_identifier(DeviceKind::PhysicalIos, "00008120-001410c11a42201e"),
933 "00008120-001410C11A42201E"
934 );
935 assert_eq!(
936 canonical_identifier(DeviceKind::Emulator, "emulator-5554"),
937 "emulator-5554"
938 );
939 assert_eq!(
940 canonical_identifier(DeviceKind::PhysicalAndroid, "abc123xyz"),
941 "abc123xyz"
942 );
943 }
944
945 #[test]
946 fn an_alias_resolves_to_what_was_stored_not_to_an_upper_cased_copy() {
947 // The bug this pins: `sim resolve` used to upper-case whatever
948 // it returned, so a registered `emulator-5554` came back as
949 // `EMULATOR-5554` — a string adb does not answer to. Normalising
950 // happens once, at registration, where the kind is known.
951 let mut sims = BTreeMap::new();
952 sims.insert(
953 "emu".to_string(),
954 RegisteredSim {
955 device_name: "emu".into(),
956 udid: "emulator-5554".into(),
957 runtime: String::new(),
958 device_type: String::new(),
959 locale: None,
960 runner_port: None,
961 kind: DeviceKind::Emulator,
962 destructive_opt_in: false,
963 },
964 );
965 let reg = SimRegistry { sims };
966 assert_eq!(reg.resolve("emu").unwrap(), "emulator-5554");
967 }
968
969 #[test]
970 fn the_mismatch_says_what_that_kind_looks_like() {
971 // "not UDID-form" told an Android user the shape of a thing they
972 // were not registering. The message has to name the world they
973 // are actually in.
974 let e = identifier_fits(DeviceKind::Emulator, UDID).expect_err("must refuse");
975 let msg = e.to_string();
976 assert!(msg.contains("emulator-<port>"), "got: {msg}");
977 assert!(msg.contains("adb devices"), "got: {msg}");
978 assert!(!msg.contains("8-4-4-4-12"), "wrong world named: {msg}");
979 }
980
981 #[test]
982 fn a_registry_written_before_this_field_reads_as_simulator() {
983 // The compatibility case that matters: every existing registry on
984 // every machine was written without `kind`. Reading those as
985 // physical would put a working simulator setup behind an opt-in
986 // its owner never asked for.
987 let json = r#"{
988 "deviceName": "sim-smix-02",
989 "udid": "5D087114-ECB3-443C-8DDB-40EEF9CFB90C",
990 "runtime": "iOS-26-5",
991 "deviceType": "iPhone-17-Pro"
992 }"#;
993 let sim: RegisteredSim = serde_json::from_str(json).expect("old record still parses");
994 assert_eq!(sim.kind, DeviceKind::Simulator);
995 assert!(!sim.kind.is_physical());
996 assert!(!sim.destructive_opt_in, "opt-in defaults to off");
997 }
998
999 #[test]
1000 fn every_kind_knows_whether_it_is_physical() {
1001 assert!(!DeviceKind::Simulator.is_physical());
1002 assert!(!DeviceKind::Emulator.is_physical());
1003 assert!(DeviceKind::PhysicalIos.is_physical());
1004 assert!(DeviceKind::PhysicalAndroid.is_physical());
1005 }
1006
1007 #[test]
1008 fn kind_roundtrips_with_its_wire_spelling_pinned() {
1009 let sim = RegisteredSim {
1010 device_name: "panda".into(),
1011 kind: DeviceKind::PhysicalIos,
1012 destructive_opt_in: true,
1013 udid: "00008120-001410C11A42201E".into(),
1014 runtime: "iOS-26-5".into(),
1015 device_type: "iPhone15,4".into(),
1016 locale: None,
1017 runner_port: None,
1018 };
1019 let json = serde_json::to_string(&sim).expect("serialize");
1020 assert!(json.contains("\"physicalIos\""), "got: {json}");
1021 assert!(json.contains("\"destructiveOptIn\":true"), "got: {json}");
1022 let back: RegisteredSim = serde_json::from_str(&json).expect("deserialize");
1023 assert_eq!(back.kind, DeviceKind::PhysicalIos);
1024 assert!(back.destructive_opt_in);
1025 }
1026}