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