Skip to main content

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}