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 /// This machine's registry and a checkout's legacy book give one alias
50 /// to two different devices.
51 #[error("{}", diverged_message(alias, machine, checkouts))]
52 Diverged {
53 /// The alias both books claim.
54 alias: String,
55 /// The machine registry's path and the identifier it holds.
56 machine: (PathBuf, String),
57 /// Each checkout book that disagrees, and the identifier it holds.
58 checkouts: Vec<(PathBuf, String)>,
59 },
60}
61
62fn diverged_message(
63 alias: &str,
64 machine: &(PathBuf, String),
65 checkouts: &[(PathBuf, String)],
66) -> String {
67 let others = checkouts
68 .iter()
69 .map(|(p, id)| format!("{} says {id}", p.display()))
70 .collect::<Vec<_>>()
71 .join("; ");
72 format!(
73 "`{alias}` names two devices: this machine's registry ({}) says {}, and {others}. \
74 A checkout's book is read, never written, and it does not get to decide which \
75 device an alias drives — so nothing was driven. Make them agree: correct that \
76 file (smix does not edit it), or record the device on this machine with \
77 `smix sim register --udid <id> {alias}`.",
78 machine.0.display(),
79 machine.1
80 )
81}
82
83/// The shape of a pre-store `.smix/sims.json`.
84#[derive(Deserialize)]
85struct LegacyBook {
86 #[serde(default)]
87 sims: BTreeMap<String, RegisteredSim>,
88}
89
90/// One alias, two devices: this machine's answer and each checkout book
91/// that disagrees with it.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Divergence {
94 /// The machine registry's path and the identifier it holds.
95 pub machine: (PathBuf, String),
96 /// Each checkout book that disagrees, and the identifier it holds.
97 pub checkouts: Vec<(PathBuf, String)>,
98}
99
100/// Which book an answer came from.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum Source {
103 /// This machine's registry — where device facts live (§9 #9).
104 Machine(PathBuf),
105 /// A checkout's book that the machine registry does not also hold.
106 Checkout(PathBuf),
107 /// The one registry `SMIX_SIMS_JSON` names.
108 Named(PathBuf),
109 /// No book: the reference was the identifier itself.
110 Literal,
111}
112
113/// A device reference answered, with the book the answer came from.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Resolved {
116 /// The identifier the platform addresses the device by.
117 pub id: String,
118 /// The alias the reference matched (the reference itself for a raw id).
119 pub alias: String,
120 /// Which book answered.
121 pub source: Source,
122}
123
124/// What kind of device a registry entry names.
125///
126/// The distinction exists for one reason: what smix is allowed to do to
127/// it. A simulator can be erased and rebuilt in a minute; a phone in
128/// somebody's pocket cannot. §9#1 hangs the destructive-action guard off
129/// this field.
130///
131/// Defaults to `Simulator` when the field is absent, and that direction
132/// is deliberate. Every registry written before this field existed was
133/// written by a simulator; reading those as physical would lock a working
134/// setup behind an opt-in nobody asked for. Guessing "simulator" costs at
135/// most one missing gate on a device that never needed it — guessing
136/// "physical" breaks people who did nothing wrong.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub enum DeviceKind {
140 /// iOS Simulator.
141 #[default]
142 Simulator,
143 /// Android emulator.
144 Emulator,
145 /// A physical iPhone or iPad.
146 PhysicalIos,
147 /// A physical Android device.
148 PhysicalAndroid,
149}
150
151impl DeviceKind {
152 /// Every kind, so nothing has to keep its own copy of the list.
153 ///
154 /// It lived as a local `const ALL` inside a CLI helper, which meant a
155 /// fifth kind would have compiled everywhere and been silently
156 /// missing from that one table. The list belongs beside the enum it
157 /// lists (`code/derive-dont-copy`).
158 pub const ALL: [DeviceKind; 4] = [
159 DeviceKind::Simulator,
160 DeviceKind::Emulator,
161 DeviceKind::PhysicalIos,
162 DeviceKind::PhysicalAndroid,
163 ];
164
165 /// Is this a device somebody might be carrying around?
166 #[must_use]
167 pub fn is_physical(self) -> bool {
168 match self {
169 DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => true,
170 DeviceKind::Simulator | DeviceKind::Emulator => false,
171 }
172 }
173}
174
175/// One registered simulator.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct RegisteredSim {
178 /// Human-chosen device name (also usable as an alias).
179 #[serde(rename = "deviceName")]
180 pub device_name: String,
181 /// What kind of device this is. Absent in registries written before
182 /// physical devices were addressable — see [`DeviceKind`] for why
183 /// that reads as `Simulator`.
184 #[serde(default)]
185 pub kind: DeviceKind,
186 /// Whether destructive actions have been allowed on this device.
187 ///
188 /// Only consulted for physical devices; a simulator is never gated.
189 /// Recorded once here rather than confirmed per command, because a
190 /// confirmation that must be typed every time ends up in a script,
191 /// which is the same as not having one.
192 #[serde(default, rename = "destructiveOptIn")]
193 pub destructive_opt_in: bool,
194 /// CoreSimulator UDID.
195 pub udid: String,
196 /// Runtime identifier.
197 pub runtime: String,
198 /// Device type identifier.
199 ///
200 /// Apple's `SimDeviceType` for a simulator. For an emulator this is
201 /// where the AVD name used to live, and rows written before
202 /// [`Self::avd_name`] existed still keep it there — read it through
203 /// that method rather than from here, which is named after the
204 /// other half of its rows.
205 #[serde(rename = "deviceType")]
206 pub device_type: String,
207 /// Which AVD an emulator row names.
208 ///
209 /// The serial is a slot: `emulator-5554` belongs to whoever booted
210 /// first, so a row holding only a serial names a stranger the moment
211 /// somebody else takes that port. The AVD name survives a reboot and
212 /// a slot change, and it is the name `smix sim boot` already starts
213 /// the device by.
214 #[serde(default, rename = "avdName", skip_serializing_if = "Option::is_none")]
215 pub avd_name: Option<String>,
216 /// Desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`). When set,
217 /// `smix sim boot` enforces it via
218 /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
219 /// sim if the current locale differs. `None` (field absent) =
220 /// honor whatever locale the sim boots with, no enforcement.
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub locale: Option<String>,
223 /// Desired runner port (SmixRunner FlyingFox HTTP port). When set,
224 /// `smix runner up <alias>` binds the runner to this port instead
225 /// of the CLI default 22087. Two sims can then run their own runner
226 /// in parallel without port collision
227 /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
228 /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
229 /// when absent.
230 #[serde(
231 default,
232 rename = "runnerPort",
233 skip_serializing_if = "Option::is_none"
234 )]
235 pub runner_port: Option<u16>,
236}
237
238impl RegisteredSim {
239 /// The AVD this row names, when it names one.
240 ///
241 /// Reads the field, then the place the value lived before that field
242 /// existed. One reader, so the two spellings cannot drift apart —
243 /// and only for emulators: `deviceType` on a simulator row carries
244 /// Apple's device type, and reading that as an identity would invent
245 /// an AVD named `com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro`.
246 pub fn avd_name(&self) -> Option<&str> {
247 if self.kind != DeviceKind::Emulator {
248 return None;
249 }
250 self.avd_name
251 .as_deref()
252 .or(Some(self.device_type.as_str()))
253 .filter(|a| !a.is_empty())
254 }
255}
256
257/// What [`SimRegistry::register`] did with the alias.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum RegisterOutcome {
260 /// The alias did not exist; a new row was written.
261 Added,
262 /// The alias existed; its row was replaced.
263 Updated,
264}
265
266/// Loaded view of the registry, keyed by alias.
267#[derive(Debug, Default, Clone)]
268pub struct SimRegistry {
269 sims: BTreeMap<String, RegisteredSim>,
270}
271
272/// What a migration did, per alias.
273///
274/// Reported rather than summarised as a count. A migration is a one-way
275/// door and the thing somebody needs afterwards is the ability to look
276/// up one device by name and see what happened to it.
277#[derive(Debug, Default, Clone)]
278pub struct MigrationReport {
279 /// Aliases the destination did not have.
280 pub added: Vec<String>,
281 /// Aliases that were already there, pointing at the same device.
282 pub unchanged: Vec<String>,
283 /// Aliases that were already there and whose record changed —
284 /// which, under [`SimRegistry::merge`], can only mean consent
285 /// narrowed.
286 pub narrowed: Vec<String>,
287 /// Sources that opened and held nothing.
288 pub empty: Vec<PathBuf>,
289 /// Sources that would not open, and why.
290 pub unreadable: Vec<(PathBuf, String)>,
291}
292
293/// Every registry this machine can read, folded into one.
294#[derive(Debug, Default, Clone)]
295pub struct MergedRegistry {
296 /// The merged view, with this machine's registry as the authority.
297 ///
298 /// This used to say that which book a device came from was not
299 /// visible here, deliberately, because a device is a device. Two
300 /// things came of that on 2026-09-24: a checkout's legacy book that
301 /// gave an alias to another device won whenever its UDID sorted
302 /// first, and a consumer lost an hour to a harness reading a book
303 /// smix no longer answered from, with nothing saying which book
304 /// smix did answer from. [`Self::sources`] and [`Self::diverged`]
305 /// are the answer to both.
306 pub registry: SimRegistry,
307 /// Aliases a checkout is the only holder of, and which checkout.
308 ///
309 /// Not an error — those devices work. It is the one thing a caller
310 /// has to be able to say out loud, because a record only one tree
311 /// holds is a record the next tree cannot act on, and the whole
312 /// point of moving these was that "check who owns this runner" can
313 /// be carried out from anywhere.
314 pub unmigrated: BTreeMap<String, PathBuf>,
315 /// Which book each alias in [`Self::registry`] came from.
316 pub sources: BTreeMap<String, Source>,
317 /// Aliases the machine and a checkout give to different devices.
318 pub diverged: BTreeMap<String, Divergence>,
319}
320
321impl MergedRegistry {
322 /// Resolve a device reference, naming the book that answered.
323 ///
324 /// An alias the machine and a checkout give to different devices is
325 /// refused rather than answered: the checkout's book may stop a
326 /// decision and be named as evidence, and nothing more (§9 #9).
327 pub fn resolve_ref(&self, device_ref: &str) -> Result<Resolved, RegistryError> {
328 let alias = self.matched_alias(device_ref);
329 if let Some(a) = alias.as_deref().or(Some(device_ref))
330 && let Some(d) = self.diverged.get(a)
331 {
332 return Err(RegistryError::Diverged {
333 alias: a.to_string(),
334 machine: d.machine.clone(),
335 checkouts: d.checkouts.clone(),
336 });
337 }
338 let id = self.registry.resolve(device_ref)?;
339 let (alias, source) = match alias {
340 Some(a) => {
341 let src = self.sources.get(&a).cloned().unwrap_or(Source::Literal);
342 (a, src)
343 }
344 None => (device_ref.to_string(), Source::Literal),
345 };
346 Ok(Resolved { id, alias, source })
347 }
348
349 /// The alias a reference names: the key itself, or the entry whose
350 /// device name or identifier it is.
351 fn matched_alias(&self, device_ref: &str) -> Option<String> {
352 let sims = self.registry.sims();
353 if sims.contains_key(device_ref) {
354 return Some(device_ref.to_string());
355 }
356 sims.iter()
357 .find(|(_, s)| s.device_name == device_ref || s.udid.eq_ignore_ascii_case(device_ref))
358 .map(|(a, _)| a.clone())
359 }
360}
361
362/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
363///
364/// Answers the shape question only. It used to answer more than that —
365/// UDID-form input was treated as a deliberate instruction that skipped
366/// the registry entirely, and the CLI still short-circuits alias lookup
367/// on it. What changed on 2026-08-06 is that skipping the registry no
368/// longer means skipping every check: a raw identifier now has to be one
369/// the platform itself claims, because the shape alone stopped being
370/// evidence the moment a `devicectl` path appeared that reaches phones
371/// whose CoreDevice UUIDs wear exactly this form.
372pub fn is_udid(s: &str) -> bool {
373 let bytes = s.as_bytes();
374 if bytes.len() != 36 {
375 return false;
376 }
377 for (i, b) in bytes.iter().enumerate() {
378 match i {
379 8 | 13 | 18 | 23 => {
380 if *b != b'-' {
381 return false;
382 }
383 }
384 _ => {
385 if !b.is_ascii_hexdigit() {
386 return false;
387 }
388 }
389 }
390 }
391 true
392}
393
394/// Where an emulator alias actually points today.
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub enum EmulatorAddress {
397 /// The recorded AVD is running on the slot it was registered with.
398 At {
399 /// The serial to address it by.
400 serial: String,
401 },
402 /// The recorded AVD is running, on a different slot than recorded.
403 MovedTo {
404 /// The serial it answers on today.
405 serial: String,
406 /// The serial the registry recorded.
407 recorded: String,
408 },
409 /// The recorded AVD is not running anywhere.
410 NotRunning {
411 /// The AVD the row records.
412 avd: String,
413 /// Which AVD holds the recorded slot now, when one does.
414 slot_now: Option<String>,
415 },
416 /// The row records a slot and nothing else.
417 NoIdentityRecorded {
418 /// The slot the row records.
419 serial: String,
420 },
421}
422
423impl EmulatorAddress {
424 /// How many answers there are.
425 pub const VARIANTS: usize = 4;
426}
427
428/// Which emulator an alias names today, given what was recorded and
429/// what is running.
430///
431/// `emulator-<port>` is a slot, not a device: whoever boots first takes
432/// 5554. So a row that records only a serial names whichever emulator
433/// happens to be answering there — on 2026-09-23 that was a consumer's
434/// `qip-consumer-36`, and every alias-driven action would have installed
435/// onto their device. The AVD name is what survives a reboot and a slot
436/// change, and registration has been writing it down since the start;
437/// nothing read it.
438///
439/// The order matters. Identity is asked for first, and the running list
440/// is not consulted without one: letting an empty slot stand in for an
441/// unknown identity is precisely how a slot becomes an identity.
442pub fn emulator_address(
443 recorded_avd: Option<&str>,
444 recorded_serial: &str,
445 live: &[(String, String)],
446) -> EmulatorAddress {
447 let Some(avd) = recorded_avd.filter(|a| !a.is_empty()) else {
448 return EmulatorAddress::NoIdentityRecorded {
449 serial: recorded_serial.to_string(),
450 };
451 };
452 // Byte for byte: `adb` and `emulator -avd` both match verbatim, so a
453 // case fold here would hand back a device neither of them answers to.
454 if let Some((serial, _)) = live.iter().find(|(_, name)| name == avd) {
455 return if serial == recorded_serial {
456 EmulatorAddress::At {
457 serial: serial.clone(),
458 }
459 } else {
460 EmulatorAddress::MovedTo {
461 serial: serial.clone(),
462 recorded: recorded_serial.to_string(),
463 }
464 };
465 }
466 EmulatorAddress::NotRunning {
467 avd: avd.to_string(),
468 // Only for the sentence: the reader needs to know the slot is
469 // not merely empty, it belongs to someone. It takes no part in
470 // choosing.
471 slot_now: live
472 .iter()
473 .find(|(serial, _)| serial == recorded_serial)
474 .map(|(_, name)| name.clone()),
475 }
476}
477
478/// Why an identifier does not fit the kind it was registered under.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct IdentifierMismatch {
481 /// The identifier as given.
482 pub given: String,
483 /// What that kind's identifiers look like.
484 pub expected: &'static str,
485}
486
487impl std::fmt::Display for IdentifierMismatch {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 write!(
490 f,
491 "{:?} is not how that kind of device is identified — {}",
492 self.given, self.expected
493 )
494 }
495}
496
497/// Does this identifier fit the kind it is being registered as?
498///
499/// Shape only; whether the device exists is a separate question, asked
500/// against a different catalogue per kind, and asked by the caller.
501///
502/// The three answers differ because the world does:
503///
504/// * A **simulator** is a directory on this Mac with a CoreSimulator
505/// UDID, and `simctl` can list every one of them.
506/// * An **emulator** is named by `adb`, which calls them `emulator-<port>`
507/// and will list them too.
508/// * A **phone** has no catalogue at all. Nothing on this machine can
509/// enumerate the world's devices, so its identifier is taken as given —
510/// which is exactly why registering one has to be a deliberate act
511/// rather than a lookup. That is not a hole in the check; it is the
512/// reason the check exists.
513///
514/// # Errors
515///
516/// Returns what that kind's identifiers look like, so the message can
517/// say which of the three worlds the caller landed in the wrong one of.
518pub fn identifier_fits(kind: DeviceKind, id: &str) -> Result<(), IdentifierMismatch> {
519 let ok = match kind {
520 DeviceKind::Simulator => is_udid(id),
521 DeviceKind::Emulator => is_emulator_serial(id),
522 // No catalogue exists to check against.
523 DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => !id.trim().is_empty(),
524 };
525 if ok {
526 return Ok(());
527 }
528 Err(IdentifierMismatch {
529 given: id.to_string(),
530 expected: match kind {
531 DeviceKind::Simulator => {
532 "a simulator has a CoreSimulator UDID (8-4-4-4-12 hex); find it with `smix sim list`"
533 }
534 DeviceKind::Emulator => {
535 "adb names an emulator `emulator-<port>`, e.g. emulator-5554; find it with `adb devices`"
536 }
537 DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => {
538 "a physical device needs a non-empty identifier: a UDID for iOS, an adb serial for Android"
539 }
540 },
541 })
542}
543
544/// Put an identifier in the form its platform matches on.
545///
546/// Normalise what has a normal form; preserve what is matched verbatim.
547///
548/// Apple's identifiers are hex and canonically upper-case, and this is
549/// not a style preference: `devicectl` was measured on 2026-08-06 to
550/// reject the lower-case spelling of a UDID it accepts in upper-case
551/// (`ERROR: The specified device was not found`). Upper-casing therefore
552/// rescues a typed-in identifier rather than mangling it.
553///
554/// `adb` matches serials byte for byte, so there is nothing to rescue and
555/// everything to break: `EMULATOR-5554` is not a device, and neither is a
556/// vendor serial that came with lower-case letters in it.
557///
558/// Done once, here, where the kind is known — never again downstream. A
559/// value normalised twice by two different rules is how `sim resolve` came
560/// to hand out `EMULATOR-5554`.
561#[must_use]
562pub fn canonical_identifier(kind: DeviceKind, id: &str) -> String {
563 match kind {
564 DeviceKind::Simulator | DeviceKind::PhysicalIos => id.to_ascii_uppercase(),
565 DeviceKind::Emulator | DeviceKind::PhysicalAndroid => id.to_string(),
566 }
567}
568
569/// Whether `s` is an adb emulator serial.
570///
571/// `adb` is the one naming these, so this recognises rather than guesses:
572/// an emulator is `emulator-<port>`, and a physical device answers with a
573/// hardware serial that never takes that form. Case matters — `adb`
574/// matches serials verbatim and `EMULATOR-5554` is not a device.
575#[must_use]
576pub fn is_emulator_serial(s: &str) -> bool {
577 s.strip_prefix("emulator-")
578 .is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
579}
580
581/// Resolve a caller-supplied path to the `.smix` directory holding the
582/// store.
583///
584/// Callers pass either form: `SMIX_SIMS_JSON` documents a file, while
585/// discovery yields a directory. Accepting both is also what lets the
586/// pre-store test suite keep exercising the legacy import path
587/// unchanged, instead of being rewritten into agreement with the code
588/// it is supposed to check.
589pub fn store_dir(path: &Path) -> PathBuf {
590 smix_dir(path)
591}
592
593fn smix_dir(path: &Path) -> PathBuf {
594 if path.extension().is_some_and(|e| e == "json") {
595 path.parent().unwrap_or(path).to_path_buf()
596 } else {
597 path.to_path_buf()
598 }
599}
600
601/// Open the store under `path` and fold in any legacy `sims.json`
602/// sitting beside it.
603///
604/// The legacy file is read, never written and never removed: a user who
605/// has to go back to a pre-store smix must still find their registry.
606fn open_store(path: &Path) -> Result<smix_store::Store, RegistryError> {
607 let dir = smix_dir(path);
608 std::fs::create_dir_all(&dir).map_err(|source| RegistryError::Io {
609 path: dir.display().to_string(),
610 source,
611 })?;
612 let store = smix_store::Store::open(&dir).map_err(|e| RegistryError::Io {
613 path: dir.display().to_string(),
614 source: std::io::Error::other(e.to_string()),
615 })?;
616 let legacy = dir.join("sims.json");
617 smix_store::import_legacy_records(&store.sims(), &legacy, "sims").map_err(|e| {
618 RegistryError::Malformed {
619 path: legacy.display().to_string(),
620 detail: e.to_string(),
621 }
622 })?;
623 Ok(store)
624}
625
626impl SimRegistry {
627 /// Write `sim` into the registry under `alias`.
628 ///
629 /// One key, not a whole file. The read-modify-write this replaces
630 /// lost an alias whenever two processes registered at once — each
631 /// read the file, each inserted its own row, and the second write
632 /// erased the first, with no error on either side.
633 pub fn register(
634 path: &Path,
635 alias: &str,
636 sim: RegisteredSim,
637 ) -> Result<RegisterOutcome, RegistryError> {
638 let store = open_store(path)?;
639 let existed = store
640 .sims()
641 .get(alias)
642 .map_err(|e| RegistryError::Malformed {
643 path: path.display().to_string(),
644 detail: e.to_string(),
645 })?
646 .is_some();
647 store
648 .sims()
649 .put_json(alias, &sim)
650 .map_err(|e| RegistryError::Io {
651 path: path.display().to_string(),
652 source: std::io::Error::other(e.to_string()),
653 })?;
654 store.sync().map_err(|e| RegistryError::Io {
655 path: path.display().to_string(),
656 source: std::io::Error::other(e.to_string()),
657 })?;
658 Ok(if existed {
659 RegisterOutcome::Updated
660 } else {
661 RegisterOutcome::Added
662 })
663 }
664
665 /// Record which alias a project defaults to, keyed by the project's
666 /// path. The value is the alias string only — a pointer into the
667 /// registered devices, never a device fact. What the alias resolves
668 /// to (UDID, kind, opt-in) stays in the machine registry; this says
669 /// only "this project drives that one", which is a project's to
670 /// decide and safe to keep per-project (§9 #9: the pointer may live
671 /// with the project, the facts may not).
672 ///
673 /// # Errors
674 /// [`RegistryError::Io`] if the store cannot be opened or written.
675 pub fn set_project_alias(
676 path: &Path,
677 project_key: &str,
678 alias: &str,
679 ) -> Result<(), RegistryError> {
680 let store = open_store(path)?;
681 store
682 .project_devices()
683 .put(project_key, alias.as_bytes())
684 .map_err(|e| RegistryError::Io {
685 path: path.display().to_string(),
686 source: std::io::Error::other(e.to_string()),
687 })?;
688 store.sync().map_err(|e| RegistryError::Io {
689 path: path.display().to_string(),
690 source: std::io::Error::other(e.to_string()),
691 })
692 }
693
694 /// The alias a project defaults to, or `None` if it has never set
695 /// one. Read-only counterpart of [`Self::set_project_alias`].
696 ///
697 /// # Errors
698 /// [`RegistryError::Io`] if the store cannot be opened or read.
699 pub fn project_alias(path: &Path, project_key: &str) -> Result<Option<String>, RegistryError> {
700 let store = open_store(path)?;
701 let raw = store
702 .project_devices()
703 .get(project_key)
704 .map_err(|e| RegistryError::Io {
705 path: path.display().to_string(),
706 source: std::io::Error::other(e.to_string()),
707 })?;
708 Ok(raw.map(|bytes| String::from_utf8_lossy(&bytes).into_owned()))
709 }
710
711 /// Remove one alias from the registry at `path`.
712 ///
713 /// The other half of [`Self::register`], and absent until the
714 /// records moved to machine scope made its absence permanent: a
715 /// device registered by mistake — a test that wrote to the real
716 /// book, an alias for a phone somebody no longer has — could be
717 /// added and never taken back. A registry that only grows stops
718 /// describing the machine.
719 ///
720 /// Removes the name, not the device. Another alias for the same
721 /// device keeps working, which is why this takes an alias key
722 /// rather than a device ref: "forget this device" and "forget this
723 /// name for it" are different requests, and only the second one is
724 /// unambiguous.
725 ///
726 /// # Errors
727 ///
728 /// [`RegistryError::UnknownDevice`] when no such alias exists.
729 /// Silently succeeding would let a typo read as a removal.
730 pub fn unregister(path: &Path, alias: &str) -> Result<RegisteredSim, RegistryError> {
731 let reg = Self::load(path)?;
732 let Some(sim) = reg.sims.get(alias).cloned() else {
733 return Err(RegistryError::UnknownDevice {
734 device_ref: alias.to_string(),
735 known: reg.sims.keys().cloned().collect(),
736 });
737 };
738 let store = open_store(path)?;
739 store.sims().delete(alias).map_err(|e| RegistryError::Io {
740 path: path.display().to_string(),
741 source: std::io::Error::other(e.to_string()),
742 })?;
743 store.sync().map_err(|e| RegistryError::Io {
744 path: path.display().to_string(),
745 source: std::io::Error::other(e.to_string()),
746 })?;
747 Ok(sim)
748 }
749
750 /// Allow destructive actions on one registered device, once.
751 ///
752 /// Goes through [`Self::register`] rather than rewriting the file,
753 /// for the reason that function documents: a read-modify-write of the
754 /// whole registry loses a concurrent registration silently. One key
755 /// in, one key out.
756 ///
757 /// Returns the alias it was recorded against and whether it was
758 /// already allowed — the caller can then say "already allowed"
759 /// instead of implying something changed.
760 ///
761 /// # Errors
762 ///
763 /// [`RegistryError::UnknownDevice`] when nothing matches the ref. The
764 /// opt-in is per device, so there is nothing to record it against —
765 /// and silently creating an entry would mean allowing destruction on
766 /// a device nobody registered.
767 pub fn allow_destructive(
768 path: &Path,
769 device_ref: &str,
770 ) -> Result<(String, bool), RegistryError> {
771 let reg = Self::load(path)?;
772 let Some((alias, sim)) = reg
773 .sims()
774 .iter()
775 .find(|(alias, sim)| {
776 alias.as_str() == device_ref
777 || sim.device_name == device_ref
778 || sim.udid.eq_ignore_ascii_case(device_ref)
779 })
780 .map(|(a, s)| (a.clone(), s.clone()))
781 else {
782 let mut known: Vec<String> = Vec::new();
783 for (alias, sim) in reg.sims() {
784 known.push(alias.clone());
785 known.push(sim.device_name.clone());
786 }
787 return Err(RegistryError::UnknownDevice {
788 device_ref: device_ref.to_string(),
789 known,
790 });
791 };
792 if sim.destructive_opt_in {
793 return Ok((alias, true));
794 }
795 let updated = RegisteredSim {
796 destructive_opt_in: true,
797 ..sim
798 };
799 Self::register(path, &alias, updated)?;
800 Ok((alias, false))
801 }
802
803 /// Read every registered sim.
804 ///
805 /// `path` may be the `.smix` directory or a legacy `sims.json`
806 /// inside it; both land on the same store.
807 pub fn load(path: &Path) -> Result<Self, RegistryError> {
808 let store = open_store(path)?;
809 let mut sims = BTreeMap::new();
810 for alias in store.sims().list() {
811 let sim: RegisteredSim = store
812 .sims()
813 .get_json(&alias)
814 .map_err(|e| RegistryError::Malformed {
815 path: path.display().to_string(),
816 detail: e.to_string(),
817 })?
818 .ok_or_else(|| RegistryError::Malformed {
819 path: path.display().to_string(),
820 detail: format!("`{alias}` vanished between listing and reading"),
821 })?;
822 sims.insert(alias, sim);
823 }
824 Ok(Self { sims })
825 }
826
827 /// Where device facts live: this machine, not this checkout.
828 ///
829 /// A simulator is an operating-system object. Its UDID, its runtime
830 /// version, whether it is booted and who booted it do not change
831 /// when you `cd` — so storing them per checkout means four trees on
832 /// one machine each hold their own answer, which is what they do
833 /// today. On 2026-08-11 two of them held a lease on the same
834 /// simulators, and a runner on port 22087 was simultaneously on the
835 /// books and invisible: the rule says check the owner before
836 /// touching a runner, and the checkout doing the checking was not
837 /// the one holding the record.
838 ///
839 /// `$XDG_DATA_HOME/smix` or `~/.local/share/smix`, which is where
840 /// the runner tree and its version stamp already live — machine
841 /// scope is not a new idea here, it was just not used for this.
842 ///
843 /// `None` when neither variable is set, which is the same condition
844 /// under which the runner tree has no home either; the caller falls
845 /// back to the checkout and says so.
846 pub fn machine_dir() -> Option<PathBuf> {
847 smix_lease::store::machine_root().map(|r| r.join("devices"))
848 }
849
850 /// Every registry a read may draw on, in precedence order.
851 ///
852 /// `SMIX_SIMS_JSON` names one registry and means exactly that one:
853 /// it is how a test works against a book of its own, and a
854 /// machine-level fallback under it would let the real one leak in.
855 ///
856 /// Otherwise the machine registry first, then whatever checkout is
857 /// underfoot. The checkout entry keeps books written before this
858 /// move working until `smix sim migrate` folds them in; nothing is
859 /// ever written back to it.
860 pub fn read_paths(start: &Path) -> Vec<PathBuf> {
861 if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
862 return vec![PathBuf::from(p)];
863 }
864 let mut paths = Vec::new();
865 if let Some(m) = Self::machine_dir() {
866 paths.push(m);
867 }
868 if let Some(c) = Self::discover(start) {
869 paths.push(c);
870 }
871 paths
872 }
873
874 /// Read every registry that applies and fold them into one.
875 ///
876 /// A source that will not open is skipped rather than fatal: one
877 /// corrupt book must not strand the devices recorded in the others.
878 /// Which is why this returns a value and not a `Result` — there is
879 /// no failure here other than "nothing was readable anywhere", and
880 /// that is an empty registry, which reads the same as a machine
881 /// where nothing has been registered yet.
882 pub fn open_all(start: &Path) -> MergedRegistry {
883 // `SMIX_SIMS_JSON` names one registry and means exactly that one:
884 // there is no machine/checkout split to report, and calling the
885 // caller's own choice "unmigrated" would be advice to move a book
886 // they put where they wanted it.
887 if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
888 let path = PathBuf::from(p);
889 let registry = Self::load(&path).unwrap_or_default();
890 let sources = registry
891 .sims
892 .keys()
893 .map(|a| (a.clone(), Source::Named(path.clone())))
894 .collect();
895 return MergedRegistry {
896 registry,
897 unmigrated: BTreeMap::new(),
898 sources,
899 diverged: BTreeMap::new(),
900 };
901 }
902 let machine = Self::machine_dir();
903 let checkouts: Vec<PathBuf> = Self::discover(start).into_iter().collect();
904 Self::open_books(machine.as_deref(), &checkouts)
905 }
906
907 /// Fold this machine's registry and the checkout books named here.
908 ///
909 /// The machine is the authority (§9 #9). Checkout books are merged
910 /// among themselves the way they always were — they have no order —
911 /// and then may add only aliases the machine does not hold. An alias
912 /// both hold for the same device keeps the machine's row, with
913 /// destructive consent narrowed if the checkout withheld it (a stop,
914 /// which is a power a checkout keeps). An alias both hold for
915 /// different devices goes into [`MergedRegistry::diverged`] and is
916 /// refused at resolution; the checkout's row does not enter the view
917 /// under any name, because a suffixed alias is a name nobody gave.
918 ///
919 /// A book that will not open is skipped: one corrupt file must not
920 /// strand the devices recorded in the others.
921 pub fn open_books(machine: Option<&Path>, checkouts: &[PathBuf]) -> MergedRegistry {
922 let machine = machine.and_then(|m| Self::load(m).ok().map(|r| (m.to_path_buf(), r)));
923 let mut books: Vec<(PathBuf, Self)> = checkouts
924 .iter()
925 .flat_map(|c| Self::checkout_books(c))
926 .collect();
927 books.sort_by(|a, b| a.0.cmp(&b.0));
928 Self::combine(machine, books)
929 }
930
931 /// The books a checkout's `.smix` holds, read without writing.
932 ///
933 /// A pre-store `sims.json` and a store written by an older smix are
934 /// two books, read separately so a disagreement can name the file.
935 /// Neither is opened for writing: reading one used to create a store
936 /// in the checkout and import the JSON into it, which put device
937 /// facts back into a checkout on every read.
938 fn checkout_books(path: &Path) -> Vec<(PathBuf, Self)> {
939 let dir = smix_dir(path);
940 let mut out = Vec::new();
941 if dir.join("kv").is_dir()
942 && let Ok(store) = smix_store::Store::open(&dir)
943 {
944 let mut sims = BTreeMap::new();
945 for alias in store.sims().list() {
946 if let Ok(Some(sim)) = store.sims().get_json::<RegisteredSim>(&alias) {
947 sims.insert(alias, sim);
948 }
949 }
950 out.push((dir.clone(), Self { sims }));
951 }
952 let legacy = dir.join("sims.json");
953 if let Ok(bytes) = std::fs::read(&legacy)
954 && let Ok(doc) = serde_json::from_slice::<LegacyBook>(&bytes)
955 {
956 out.push((legacy, Self { sims: doc.sims }));
957 }
958 out
959 }
960
961 /// The pure half of [`Self::open_books`]: already-read books in,
962 /// one view out.
963 fn combine(machine: Option<(PathBuf, Self)>, books: Vec<(PathBuf, Self)>) -> MergedRegistry {
964 let (mpath, mut mreg) = match machine {
965 Some((p, r)) => (Some(p), r),
966 None => (None, Self::default()),
967 };
968 let mut sources: BTreeMap<String, Source> = BTreeMap::new();
969 if let Some(p) = &mpath {
970 for alias in mreg.sims.keys() {
971 sources.insert(alias.clone(), Source::Machine(p.clone()));
972 }
973 }
974 let mut diverged: BTreeMap<String, Divergence> = BTreeMap::new();
975 let mut rest: Vec<Self> = Vec::new();
976 let mut rest_paths: Vec<(PathBuf, Self)> = Vec::new();
977 for (path, book) in books {
978 let mut kept = BTreeMap::new();
979 for (alias, sim) in book.sims {
980 match (mreg.sims.get_mut(&alias), &mpath) {
981 (Some(own), _) if own.udid.eq_ignore_ascii_case(&sim.udid) => {
982 own.destructive_opt_in = own.destructive_opt_in && sim.destructive_opt_in;
983 }
984 (Some(own), Some(mp)) => {
985 diverged
986 .entry(alias)
987 .or_insert_with(|| Divergence {
988 machine: (mp.clone(), own.udid.clone()),
989 checkouts: Vec::new(),
990 })
991 .checkouts
992 .push((path.clone(), sim.udid));
993 }
994 _ => {
995 kept.insert(alias, sim);
996 }
997 }
998 }
999 rest_paths.push((path.clone(), Self { sims: kept.clone() }));
1000 rest.push(Self { sims: kept });
1001 }
1002 let checkout_only = Self::merge(rest);
1003 let on_machine: std::collections::BTreeSet<String> = mreg
1004 .sims
1005 .values()
1006 .map(|s| s.udid.to_ascii_uppercase())
1007 .collect();
1008 let mut unmigrated = BTreeMap::new();
1009 for (alias, sim) in checkout_only.sims {
1010 let from = rest_paths
1011 .iter()
1012 .find(|(_, b)| {
1013 b.sims
1014 .values()
1015 .any(|s| s.udid.eq_ignore_ascii_case(&sim.udid))
1016 })
1017 .map(|(p, _)| p.clone())
1018 .unwrap_or_default();
1019 if mpath.is_some() && !on_machine.contains(&sim.udid.to_ascii_uppercase()) {
1020 unmigrated.insert(alias.clone(), from.clone());
1021 }
1022 sources.insert(alias.clone(), Source::Checkout(from));
1023 mreg.sims.insert(alias, sim);
1024 }
1025 MergedRegistry {
1026 registry: mreg,
1027 unmigrated,
1028 sources,
1029 diverged,
1030 }
1031 }
1032
1033 /// Walk up from `start` looking for a `.smix` that holds a
1034 /// registry — either the store or a legacy `sims.json`.
1035 pub fn discover(start: &Path) -> Option<PathBuf> {
1036 let mut dir = Some(start);
1037 while let Some(d) = dir {
1038 let smix = d.join(".smix");
1039 if smix.join("sims.json").is_file() || smix.join("kv").is_dir() {
1040 return Some(smix);
1041 }
1042 dir = d.parent();
1043 }
1044 None
1045 }
1046
1047 /// Resolve a device ref to the identifier its platform is addressed by.
1048 ///
1049 /// CoreSimulator-form input passes through whether or not it is
1050 /// registered. Otherwise the ref must match an alias key, a
1051 /// `deviceName`, or the registered identifier itself.
1052 ///
1053 /// That last one was missing until 2026-08-06, and [`Self::lookup`]
1054 /// had it — so the two disagreed about whether a device's own
1055 /// identifier names it. A real phone found the disagreement: an iOS
1056 /// device UDID is 25 characters, not CoreSimulator's 36, so it fell
1057 /// past the short-circuit into a search that never looked at the one
1058 /// field it matched. `smix runner forward 00008120-…` answered
1059 /// "unknown device ref" about a device that was registered right
1060 /// there in the file it was reading.
1061 pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
1062 if is_udid(device_ref) {
1063 return Ok(device_ref.to_ascii_uppercase());
1064 }
1065 // Stored verbatim, returned verbatim. The value was already put
1066 // in its platform's form at registration by
1067 // [`canonical_identifier`]; upper-casing it a second time here is
1068 // what turned a registered `emulator-5554` into the
1069 // `EMULATOR-5554` that adb does not answer to.
1070 if let Some(sim) = self.sims.get(device_ref) {
1071 return Ok(sim.udid.clone());
1072 }
1073 if let Some(sim) = self
1074 .sims
1075 .values()
1076 .find(|s| s.device_name == device_ref || s.udid.eq_ignore_ascii_case(device_ref))
1077 {
1078 return Ok(sim.udid.clone());
1079 }
1080 // Deduplicated: an alias and a device name are commonly the same
1081 // word, and "one of the recorded aliases: phone, phone" reads as
1082 // a bug in the tool rather than a list of choices.
1083 let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
1084 for (alias, sim) in &self.sims {
1085 for name in [alias, &sim.device_name] {
1086 if !known.iter().any(|k| k == name) {
1087 known.push(name.clone());
1088 }
1089 }
1090 }
1091 Err(RegistryError::UnknownDevice {
1092 device_ref: device_ref.to_string(),
1093 known,
1094 })
1095 }
1096
1097 /// All registered sims, keyed by alias.
1098 /// Fold several registries into one, losing nothing.
1099 ///
1100 /// Four checkouts on this machine each keep their own, and merging
1101 /// them is a one-way door: whatever this drops, the tree that was
1102 /// relying on it stops working, and nobody can tell which of the
1103 /// four to look in. So the rules are chosen to be safe, not tidy.
1104 ///
1105 /// - **Two aliases for one device: keep both.** An alias is how
1106 /// somebody types a device's name. Dropping one breaks whatever
1107 /// script used it; a duplicate is noise.
1108 /// - **Conflicting destructive consent: take the stricter.**
1109 /// Consent is a per-device authorisation (§9 #1), and merging two
1110 /// books is not a moment to widen it. Granting it again is one
1111 /// command; un-wiping a phone is not a command at all.
1112 /// - **Same alias, different devices: keep both**, the later one
1113 /// under a suffixed alias. Silently overwriting means one tree's
1114 /// alias stops resolving with no way to know which.
1115 ///
1116 /// Order-independent for the facts that matter: four checkouts have
1117 /// no natural order, and a merge that depends on read order changes
1118 /// when somebody renames a directory.
1119 pub fn merge(sources: impl IntoIterator<Item = Self>) -> Self {
1120 let mut out: BTreeMap<String, RegisteredSim> = BTreeMap::new();
1121 // Sorted, so the result cannot depend on which tree was read
1122 // first.
1123 let mut incoming: Vec<(String, RegisteredSim)> = sources
1124 .into_iter()
1125 .flat_map(|r| r.sims.into_iter())
1126 .collect();
1127 incoming.sort_by(|a, b| (&a.0, &a.1.udid).cmp(&(&b.0, &b.1.udid)));
1128
1129 for (alias, sim) in incoming {
1130 match out.get_mut(&alias) {
1131 None => {
1132 out.insert(alias, sim);
1133 }
1134 Some(existing) if existing.udid == sim.udid => {
1135 // Same device under the same name: the only thing
1136 // that can differ is consent, and it narrows.
1137 existing.destructive_opt_in =
1138 existing.destructive_opt_in && sim.destructive_opt_in;
1139 }
1140 Some(_) => {
1141 // Same name, different device. Both survive; the
1142 // second takes a suffix derived from its UDID so the
1143 // name is stable across merges rather than depending
1144 // on how many collisions came before it.
1145 let short = sim.udid.chars().take(8).collect::<String>().to_lowercase();
1146 out.insert(format!("{alias}-{short}"), sim);
1147 }
1148 }
1149 }
1150 Self { sims: out }
1151 }
1152
1153 /// Fold `sources` into the registry at `into`, keeping everything.
1154 ///
1155 /// The merge rules are [`Self::merge`]'s; this applies them to books
1156 /// on disk. What it adds to them is a promise about the sources:
1157 /// they are read and left alone. Somebody who has to go back to a
1158 /// smix from before device records became machine-scoped must still
1159 /// find their registry where they left it — and a migration that
1160 /// deletes what it has just copied has no way to be run twice by
1161 /// somebody who is not sure whether it worked.
1162 ///
1163 /// Writes go through [`Self::register`], one key at a time, for the
1164 /// reason that function documents: a whole-file rewrite loses a
1165 /// concurrent registration without saying so.
1166 pub fn migrate(into: &Path, sources: &[PathBuf]) -> Result<MigrationReport, RegistryError> {
1167 Self::migrate_inner(into, sources, true)
1168 }
1169
1170 /// What [`Self::migrate`] would do, having done nothing.
1171 ///
1172 /// The same function with the write skipped, rather than a second
1173 /// one that works the answer out again — a rehearsal with its own
1174 /// copy of the rules reports on its copy.
1175 ///
1176 /// # Errors
1177 ///
1178 /// As [`Self::migrate`], minus the ones only a write can raise.
1179 pub fn migrate_dry_run(
1180 into: &Path,
1181 sources: &[PathBuf],
1182 ) -> Result<MigrationReport, RegistryError> {
1183 Self::migrate_inner(into, sources, false)
1184 }
1185
1186 fn migrate_inner(
1187 into: &Path,
1188 sources: &[PathBuf],
1189 commit: bool,
1190 ) -> Result<MigrationReport, RegistryError> {
1191 let mut report = MigrationReport::default();
1192 let mut books = vec![Self::load(into)?];
1193 let before: BTreeMap<String, String> = books[0]
1194 .sims
1195 .iter()
1196 .map(|(a, s)| (a.clone(), s.udid.clone()))
1197 .collect();
1198
1199 for src in sources {
1200 match Self::load(src) {
1201 Ok(reg) if reg.sims.is_empty() => report.empty.push(src.clone()),
1202 Ok(reg) => books.push(reg),
1203 // Named, not fatal. One book nobody can open must not
1204 // strand the devices recorded in the others — and the
1205 // path is what somebody needs in order to go look.
1206 Err(e) => report.unreadable.push((src.clone(), e.to_string())),
1207 }
1208 }
1209
1210 let merged = Self::merge(books);
1211 for (alias, sim) in &merged.sims {
1212 match before.get(alias) {
1213 Some(udid) if udid == &sim.udid => report.unchanged.push(alias.clone()),
1214 Some(_) => report.narrowed.push(alias.clone()),
1215 None => report.added.push(alias.clone()),
1216 }
1217 if commit {
1218 Self::register(into, alias, sim.clone())?;
1219 }
1220 }
1221 Ok(report)
1222 }
1223
1224 /// Every alias and what it points at.
1225 pub fn all(&self) -> impl Iterator<Item = (&str, &RegisteredSim)> {
1226 self.sims.iter().map(|(k, v)| (k.as_str(), v))
1227 }
1228
1229 /// Add or replace one entry. For merging and for tests; the
1230 /// registering path goes through `register`, which also writes.
1231 pub fn insert(&mut self, alias: impl Into<String>, sim: RegisteredSim) {
1232 self.sims.insert(alias.into(), sim);
1233 }
1234
1235 /// Every registered sim, keyed by alias.
1236 pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
1237 &self.sims
1238 }
1239
1240 /// Look up a [`RegisteredSim`] by alias key, device name, or UDID.
1241 /// Returns `None` if no entry matches any of the three. Mirrors
1242 /// [`Self::resolve`]'s match precedence so cli callers can fetch
1243 /// the full spec (e.g. `locale` field) after they already resolved
1244 /// the UDID.
1245 pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
1246 if let Some(sim) = self.sims.get(device_ref) {
1247 return Some(sim);
1248 }
1249 self.sims
1250 .values()
1251 .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
1252 }
1253}
1254
1255#[cfg(test)]
1256mod kind_tests {
1257 use super::*;
1258
1259 const UDID: &str = "47ACEAE5-36BA-4C62-811B-F09B397910D7";
1260
1261 #[test]
1262 fn each_virtual_kind_takes_its_own_platforms_identifiers() {
1263 assert!(identifier_fits(DeviceKind::Simulator, UDID).is_ok());
1264 assert!(identifier_fits(DeviceKind::Emulator, "emulator-5554").is_ok());
1265 // And not each other's. A UDID registered as an emulator would
1266 // be an alias for something adb can never be handed.
1267 assert!(identifier_fits(DeviceKind::Simulator, "emulator-5554").is_err());
1268 assert!(identifier_fits(DeviceKind::Emulator, UDID).is_err());
1269 }
1270
1271 #[test]
1272 fn a_physical_identifier_is_taken_as_given() {
1273 // Nothing on this machine can enumerate the world's phones, so
1274 // there is no catalogue to check against — which is precisely
1275 // why registering one is a deliberate act. Both spellings are
1276 // legitimate: a UDID for iOS, an adb serial for Android.
1277 assert!(identifier_fits(DeviceKind::PhysicalIos, "00008120-001410C11A42201E").is_ok());
1278 assert!(identifier_fits(DeviceKind::PhysicalAndroid, "R5CT52DF07D").is_ok());
1279 // Empty is still nothing.
1280 assert!(identifier_fits(DeviceKind::PhysicalIos, " ").is_err());
1281 }
1282
1283 #[test]
1284 fn an_emulator_serial_is_recognised_not_guessed() {
1285 assert!(is_emulator_serial("emulator-5554"));
1286 assert!(!is_emulator_serial("emulator-"));
1287 assert!(!is_emulator_serial("emulator-abcd"));
1288 // Case matters: adb matches serials verbatim, and this is not a
1289 // device. The UDID path upper-cases; this one must not.
1290 assert!(!is_emulator_serial("EMULATOR-5554"));
1291 assert!(!is_emulator_serial("R5CT52DF07D"));
1292 }
1293
1294 #[test]
1295 fn apple_identifiers_are_normalised_and_adb_serials_are_not() {
1296 // Measured, not assumed: `devicectl` rejects the lower-case
1297 // spelling of a UDID it accepts in upper-case, so upper-casing
1298 // an Apple identifier rescues it. `adb` matches byte for byte,
1299 // so the same move would break it.
1300 assert_eq!(
1301 canonical_identifier(
1302 DeviceKind::Simulator,
1303 "47aceae5-36ba-4c62-811b-f09b397910d7"
1304 ),
1305 "47ACEAE5-36BA-4C62-811B-F09B397910D7"
1306 );
1307 assert_eq!(
1308 canonical_identifier(DeviceKind::PhysicalIos, "00008120-001410c11a42201e"),
1309 "00008120-001410C11A42201E"
1310 );
1311 assert_eq!(
1312 canonical_identifier(DeviceKind::Emulator, "emulator-5554"),
1313 "emulator-5554"
1314 );
1315 assert_eq!(
1316 canonical_identifier(DeviceKind::PhysicalAndroid, "abc123xyz"),
1317 "abc123xyz"
1318 );
1319 }
1320
1321 #[test]
1322 fn an_alias_resolves_to_what_was_stored_not_to_an_upper_cased_copy() {
1323 // The bug this pins: `sim resolve` used to upper-case whatever
1324 // it returned, so a registered `emulator-5554` came back as
1325 // `EMULATOR-5554` — a string adb does not answer to. Normalising
1326 // happens once, at registration, where the kind is known.
1327 let mut sims = BTreeMap::new();
1328 sims.insert(
1329 "emu".to_string(),
1330 RegisteredSim {
1331 device_name: "emu".into(),
1332 udid: "emulator-5554".into(),
1333 runtime: String::new(),
1334 device_type: String::new(),
1335 avd_name: None,
1336 locale: None,
1337 runner_port: None,
1338 kind: DeviceKind::Emulator,
1339 destructive_opt_in: false,
1340 },
1341 );
1342 let reg = SimRegistry { sims };
1343 assert_eq!(reg.resolve("emu").unwrap(), "emulator-5554");
1344 }
1345
1346 #[test]
1347 fn the_mismatch_says_what_that_kind_looks_like() {
1348 // "not UDID-form" told an Android user the shape of a thing they
1349 // were not registering. The message has to name the world they
1350 // are actually in.
1351 let e = identifier_fits(DeviceKind::Emulator, UDID).expect_err("must refuse");
1352 let msg = e.to_string();
1353 assert!(msg.contains("emulator-<port>"), "got: {msg}");
1354 assert!(msg.contains("adb devices"), "got: {msg}");
1355 assert!(!msg.contains("8-4-4-4-12"), "wrong world named: {msg}");
1356 }
1357
1358 #[test]
1359 fn a_registry_written_before_this_field_reads_as_simulator() {
1360 // The compatibility case that matters: every existing registry on
1361 // every machine was written without `kind`. Reading those as
1362 // physical would put a working simulator setup behind an opt-in
1363 // its owner never asked for.
1364 let json = r#"{
1365 "deviceName": "sim-smix-02",
1366 "udid": "5D087114-ECB3-443C-8DDB-40EEF9CFB90C",
1367 "runtime": "iOS-26-5",
1368 "deviceType": "iPhone-17-Pro"
1369 }"#;
1370 let sim: RegisteredSim = serde_json::from_str(json).expect("old record still parses");
1371 assert_eq!(sim.kind, DeviceKind::Simulator);
1372 assert!(!sim.kind.is_physical());
1373 assert!(!sim.destructive_opt_in, "opt-in defaults to off");
1374 }
1375
1376 #[test]
1377 fn every_kind_knows_whether_it_is_physical() {
1378 assert!(!DeviceKind::Simulator.is_physical());
1379 assert!(!DeviceKind::Emulator.is_physical());
1380 assert!(DeviceKind::PhysicalIos.is_physical());
1381 assert!(DeviceKind::PhysicalAndroid.is_physical());
1382 }
1383
1384 #[test]
1385 fn kind_roundtrips_with_its_wire_spelling_pinned() {
1386 let sim = RegisteredSim {
1387 device_name: "panda".into(),
1388 kind: DeviceKind::PhysicalIos,
1389 destructive_opt_in: true,
1390 udid: "00008120-001410C11A42201E".into(),
1391 runtime: "iOS-26-5".into(),
1392 device_type: "iPhone15,4".into(),
1393 avd_name: None,
1394 locale: None,
1395 runner_port: None,
1396 };
1397 let json = serde_json::to_string(&sim).expect("serialize");
1398 assert!(json.contains("\"physicalIos\""), "got: {json}");
1399 assert!(json.contains("\"destructiveOptIn\":true"), "got: {json}");
1400 let back: RegisteredSim = serde_json::from_str(&json).expect("deserialize");
1401 assert_eq!(back.kind, DeviceKind::PhysicalIos);
1402 assert!(back.destructive_opt_in);
1403 }
1404}
1405
1406#[cfg(test)]
1407mod emulator_address_tests {
1408 use super::*;
1409
1410 fn live(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
1411 pairs
1412 .iter()
1413 .map(|(s, a)| ((*s).to_string(), (*a).to_string()))
1414 .collect()
1415 }
1416
1417 #[test]
1418 fn an_avd_still_in_the_slot_it_was_registered_on_is_addressed_there() {
1419 let out = emulator_address(
1420 Some("a-01"),
1421 "emulator-5554",
1422 &live(&[("emulator-5554", "a-01")]),
1423 );
1424 assert_eq!(
1425 out,
1426 EmulatorAddress::At {
1427 serial: "emulator-5554".into()
1428 }
1429 );
1430 }
1431
1432 #[test]
1433 fn an_avd_that_moved_slots_is_followed_not_looked_up_by_slot() {
1434 // This machine, 2026-09-23: the row says 5554, the AVD is on
1435 // 5556, and 5554 now hosts somebody else's emulator.
1436 let out = emulator_address(
1437 Some("a-01"),
1438 "emulator-5554",
1439 &live(&[
1440 ("emulator-5554", "qip-consumer-36"),
1441 ("emulator-5556", "a-01"),
1442 ]),
1443 );
1444 assert_eq!(
1445 out,
1446 EmulatorAddress::MovedTo {
1447 serial: "emulator-5556".into(),
1448 recorded: "emulator-5554".into()
1449 }
1450 );
1451 }
1452
1453 #[test]
1454 fn an_avd_that_is_not_running_says_who_holds_the_slot_it_used_to_have() {
1455 let out = emulator_address(
1456 Some("a-01"),
1457 "emulator-5554",
1458 &live(&[("emulator-5554", "qip-consumer-36")]),
1459 );
1460 assert_eq!(
1461 out,
1462 EmulatorAddress::NotRunning {
1463 avd: "a-01".into(),
1464 slot_now: Some("qip-consumer-36".into())
1465 }
1466 );
1467 }
1468
1469 #[test]
1470 fn nothing_running_is_not_running_with_nobody_in_the_slot() {
1471 let out = emulator_address(Some("a-01"), "emulator-5554", &live(&[]));
1472 assert_eq!(
1473 out,
1474 EmulatorAddress::NotRunning {
1475 avd: "a-01".into(),
1476 slot_now: None
1477 }
1478 );
1479 }
1480
1481 #[test]
1482 fn a_row_with_no_identity_says_so_whatever_is_in_the_slot() {
1483 // Both directions: an empty slot does not turn "we do not know
1484 // which device this is" into "we do". That step is exactly how a
1485 // slot quietly becomes an identity.
1486 let expected = EmulatorAddress::NoIdentityRecorded {
1487 serial: "emulator-5554".into(),
1488 };
1489 assert_eq!(
1490 emulator_address(None, "emulator-5554", &live(&[])),
1491 expected
1492 );
1493 assert_eq!(
1494 emulator_address(
1495 None,
1496 "emulator-5554",
1497 &live(&[("emulator-5554", "somebody-else")])
1498 ),
1499 expected
1500 );
1501 // An empty string is an absence that was written down, not a name.
1502 assert_eq!(
1503 emulator_address(Some(""), "emulator-5554", &live(&[])),
1504 expected
1505 );
1506 }
1507
1508 #[test]
1509 fn avd_names_are_matched_byte_for_byte() {
1510 // `adb` and `emulator -avd` both match verbatim, so a case fold
1511 // here would address a device neither of them would.
1512 let out = emulator_address(
1513 Some("A-01"),
1514 "emulator-5554",
1515 &live(&[("emulator-5554", "a-01")]),
1516 );
1517 assert_eq!(
1518 out,
1519 EmulatorAddress::NotRunning {
1520 avd: "A-01".into(),
1521 slot_now: Some("a-01".into())
1522 }
1523 );
1524 }
1525
1526 #[test]
1527 fn there_are_four_answers_and_each_carries_a_different_sentence() {
1528 // A count, not a walk: folding one answer into another is how a
1529 // refusal turns into a different refusal's wording and stops
1530 // being about what happened.
1531 assert_eq!(EmulatorAddress::VARIANTS, 4);
1532 }
1533}
1534#[cfg(test)]
1535mod avd_name_tests {
1536 use super::*;
1537
1538 fn row(kind: DeviceKind, device_type: &str, avd_name: Option<&str>) -> RegisteredSim {
1539 RegisteredSim {
1540 device_name: "d".into(),
1541 kind,
1542 destructive_opt_in: false,
1543 udid: "emulator-5554".into(),
1544 runtime: String::new(),
1545 device_type: device_type.into(),
1546 avd_name: avd_name.map(str::to_string),
1547 locale: None,
1548 runner_port: None,
1549 }
1550 }
1551
1552 #[test]
1553 fn the_new_field_is_absent_from_the_wire_until_it_is_set() {
1554 // An older reader has to keep reading these files.
1555 let json = serde_json::to_string(&row(DeviceKind::Emulator, "", None)).expect("serialize");
1556 assert!(!json.contains("avdName"), "got: {json}");
1557 let json =
1558 serde_json::to_string(&row(DeviceKind::Emulator, "", Some("a-01"))).expect("serialize");
1559 assert!(json.contains("\"avdName\":\"a-01\""), "got: {json}");
1560 let back: RegisteredSim = serde_json::from_str(&json).expect("deserialize");
1561 assert_eq!(back.avd_name(), Some("a-01"));
1562 }
1563
1564 #[test]
1565 fn an_emulator_row_written_before_the_field_existed_keeps_its_identity() {
1566 // Registration has written the AVD name into `deviceType` since
1567 // emulators became registrable. Those rows are not identityless;
1568 // their identity is in a field named after somebody else's idea.
1569 let sim = row(DeviceKind::Emulator, "sim-smix-android-01", None);
1570 assert_eq!(sim.avd_name(), Some("sim-smix-android-01"));
1571 }
1572
1573 #[test]
1574 fn a_simulators_device_type_is_not_an_avd_name() {
1575 // The same field carries Apple's device type for simulators.
1576 // Reading that as an identity would invent an AVD called
1577 // `com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro`.
1578 let sim = row(
1579 DeviceKind::Simulator,
1580 "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro",
1581 None,
1582 );
1583 assert_eq!(sim.avd_name(), None);
1584 let phone = row(DeviceKind::PhysicalAndroid, "SM-S9010", None);
1585 assert_eq!(phone.avd_name(), None);
1586 }
1587
1588 #[test]
1589 fn the_new_field_wins_when_both_are_there() {
1590 let sim = row(DeviceKind::Emulator, "old-name", Some("new-name"));
1591 assert_eq!(sim.avd_name(), Some("new-name"));
1592 }
1593}