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    /// Is this a device somebody might be carrying around?
80    #[must_use]
81    pub fn is_physical(self) -> bool {
82        matches!(self, DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid)
83    }
84}
85
86/// One registered simulator.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct RegisteredSim {
89    /// Human-chosen device name (also usable as an alias).
90    #[serde(rename = "deviceName")]
91    pub device_name: String,
92    /// What kind of device this is. Absent in registries written before
93    /// physical devices were addressable — see [`DeviceKind`] for why
94    /// that reads as `Simulator`.
95    #[serde(default)]
96    pub kind: DeviceKind,
97    /// Whether destructive actions have been allowed on this device.
98    ///
99    /// Only consulted for physical devices; a simulator is never gated.
100    /// Recorded once here rather than confirmed per command, because a
101    /// confirmation that must be typed every time ends up in a script,
102    /// which is the same as not having one.
103    #[serde(default, rename = "destructiveOptIn")]
104    pub destructive_opt_in: bool,
105    /// CoreSimulator UDID.
106    pub udid: String,
107    /// Runtime identifier.
108    pub runtime: String,
109    /// Device type identifier.
110    #[serde(rename = "deviceType")]
111    pub device_type: String,
112    /// Desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`). When set,
113    /// `smix sim boot` enforces it via
114    /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
115    /// sim if the current locale differs. `None` (field absent) =
116    /// honor whatever locale the sim boots with, no enforcement.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub locale: Option<String>,
119    /// Desired runner port (SmixRunner FlyingFox HTTP port). When set,
120    /// `smix runner up <alias>` binds the runner to this port instead
121    /// of the CLI default 22087. Two sims can then run their own runner
122    /// in parallel without port collision
123    /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
124    /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
125    /// when absent.
126    #[serde(
127        default,
128        rename = "runnerPort",
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub runner_port: Option<u16>,
132}
133
134/// What [`SimRegistry::register`] did with the alias.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum RegisterOutcome {
137    /// The alias did not exist; a new row was written.
138    Added,
139    /// The alias existed; its row was replaced.
140    Updated,
141}
142
143/// Loaded view of the registry, keyed by alias.
144#[derive(Debug)]
145pub struct SimRegistry {
146    sims: BTreeMap<String, RegisteredSim>,
147}
148
149/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
150///
151/// Answers the shape question only. It used to answer more than that —
152/// UDID-form input was treated as a deliberate instruction that skipped
153/// the registry entirely, and the CLI still short-circuits alias lookup
154/// on it. What changed on 2026-08-06 is that skipping the registry no
155/// longer means skipping every check: a raw identifier now has to be one
156/// the platform itself claims, because the shape alone stopped being
157/// evidence the moment a `devicectl` path appeared that reaches phones
158/// whose CoreDevice UUIDs wear exactly this form.
159pub fn is_udid(s: &str) -> bool {
160    let bytes = s.as_bytes();
161    if bytes.len() != 36 {
162        return false;
163    }
164    for (i, b) in bytes.iter().enumerate() {
165        match i {
166            8 | 13 | 18 | 23 => {
167                if *b != b'-' {
168                    return false;
169                }
170            }
171            _ => {
172                if !b.is_ascii_hexdigit() {
173                    return false;
174                }
175            }
176        }
177    }
178    true
179}
180
181/// Why an identifier does not fit the kind it was registered under.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct IdentifierMismatch {
184    /// The identifier as given.
185    pub given: String,
186    /// What that kind's identifiers look like.
187    pub expected: &'static str,
188}
189
190impl std::fmt::Display for IdentifierMismatch {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        write!(
193            f,
194            "{:?} is not how that kind of device is identified — {}",
195            self.given, self.expected
196        )
197    }
198}
199
200/// Does this identifier fit the kind it is being registered as?
201///
202/// Shape only; whether the device exists is a separate question, asked
203/// against a different catalogue per kind, and asked by the caller.
204///
205/// The three answers differ because the world does:
206///
207/// * A **simulator** is a directory on this Mac with a CoreSimulator
208///   UDID, and `simctl` can list every one of them.
209/// * An **emulator** is named by `adb`, which calls them `emulator-<port>`
210///   and will list them too.
211/// * A **phone** has no catalogue at all. Nothing on this machine can
212///   enumerate the world's devices, so its identifier is taken as given —
213///   which is exactly why registering one has to be a deliberate act
214///   rather than a lookup. That is not a hole in the check; it is the
215///   reason the check exists.
216///
217/// # Errors
218///
219/// Returns what that kind's identifiers look like, so the message can
220/// say which of the three worlds the caller landed in the wrong one of.
221pub fn identifier_fits(kind: DeviceKind, id: &str) -> Result<(), IdentifierMismatch> {
222    let ok = match kind {
223        DeviceKind::Simulator => is_udid(id),
224        DeviceKind::Emulator => is_emulator_serial(id),
225        // No catalogue exists to check against.
226        DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => !id.trim().is_empty(),
227    };
228    if ok {
229        return Ok(());
230    }
231    Err(IdentifierMismatch {
232        given: id.to_string(),
233        expected: match kind {
234            DeviceKind::Simulator => {
235                "a simulator has a CoreSimulator UDID (8-4-4-4-12 hex); find it with `smix sim list`"
236            }
237            DeviceKind::Emulator => {
238                "adb names an emulator `emulator-<port>`, e.g. emulator-5554; find it with `adb devices`"
239            }
240            DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => {
241                "a physical device needs a non-empty identifier: a UDID for iOS, an adb serial for Android"
242            }
243        },
244    })
245}
246
247/// Put an identifier in the form its platform matches on.
248///
249/// Normalise what has a normal form; preserve what is matched verbatim.
250///
251/// Apple's identifiers are hex and canonically upper-case, and this is
252/// not a style preference: `devicectl` was measured on 2026-08-06 to
253/// reject the lower-case spelling of a UDID it accepts in upper-case
254/// (`ERROR: The specified device was not found`). Upper-casing therefore
255/// rescues a typed-in identifier rather than mangling it.
256///
257/// `adb` matches serials byte for byte, so there is nothing to rescue and
258/// everything to break: `EMULATOR-5554` is not a device, and neither is a
259/// vendor serial that came with lower-case letters in it.
260///
261/// Done once, here, where the kind is known — never again downstream. A
262/// value normalised twice by two different rules is how `sim resolve` came
263/// to hand out `EMULATOR-5554`.
264#[must_use]
265pub fn canonical_identifier(kind: DeviceKind, id: &str) -> String {
266    match kind {
267        DeviceKind::Simulator | DeviceKind::PhysicalIos => id.to_ascii_uppercase(),
268        DeviceKind::Emulator | DeviceKind::PhysicalAndroid => id.to_string(),
269    }
270}
271
272/// Whether `s` is an adb emulator serial.
273///
274/// `adb` is the one naming these, so this recognises rather than guesses:
275/// an emulator is `emulator-<port>`, and a physical device answers with a
276/// hardware serial that never takes that form. Case matters — `adb`
277/// matches serials verbatim and `EMULATOR-5554` is not a device.
278#[must_use]
279pub fn is_emulator_serial(s: &str) -> bool {
280    s.strip_prefix("emulator-")
281        .is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
282}
283
284/// Resolve a caller-supplied path to the `.smix` directory holding the
285/// store.
286///
287/// Callers pass either form: `SMIX_SIMS_JSON` documents a file, while
288/// discovery yields a directory. Accepting both is also what lets the
289/// pre-store test suite keep exercising the legacy import path
290/// unchanged, instead of being rewritten into agreement with the code
291/// it is supposed to check.
292pub fn store_dir(path: &Path) -> PathBuf {
293    smix_dir(path)
294}
295
296fn smix_dir(path: &Path) -> PathBuf {
297    if path.extension().is_some_and(|e| e == "json") {
298        path.parent().unwrap_or(path).to_path_buf()
299    } else {
300        path.to_path_buf()
301    }
302}
303
304/// Open the store under `path` and fold in any legacy `sims.json`
305/// sitting beside it.
306///
307/// The legacy file is read, never written and never removed: a user who
308/// has to go back to a pre-store smix must still find their registry.
309fn open_store(path: &Path) -> Result<smix_store::Store, RegistryError> {
310    let dir = smix_dir(path);
311    std::fs::create_dir_all(&dir).map_err(|source| RegistryError::Io {
312        path: dir.display().to_string(),
313        source,
314    })?;
315    let store = smix_store::Store::open(&dir).map_err(|e| RegistryError::Io {
316        path: dir.display().to_string(),
317        source: std::io::Error::other(e.to_string()),
318    })?;
319    let legacy = dir.join("sims.json");
320    smix_store::import_legacy_records(&store.sims(), &legacy, "sims").map_err(|e| {
321        RegistryError::Malformed {
322            path: legacy.display().to_string(),
323            detail: e.to_string(),
324        }
325    })?;
326    Ok(store)
327}
328
329impl SimRegistry {
330    /// Write `sim` into the registry under `alias`.
331    ///
332    /// One key, not a whole file. The read-modify-write this replaces
333    /// lost an alias whenever two processes registered at once — each
334    /// read the file, each inserted its own row, and the second write
335    /// erased the first, with no error on either side.
336    pub fn register(
337        path: &Path,
338        alias: &str,
339        sim: RegisteredSim,
340    ) -> Result<RegisterOutcome, RegistryError> {
341        let store = open_store(path)?;
342        let existed = store
343            .sims()
344            .get(alias)
345            .map_err(|e| RegistryError::Malformed {
346                path: path.display().to_string(),
347                detail: e.to_string(),
348            })?
349            .is_some();
350        store
351            .sims()
352            .put_json(alias, &sim)
353            .map_err(|e| RegistryError::Io {
354                path: path.display().to_string(),
355                source: std::io::Error::other(e.to_string()),
356            })?;
357        store.sync().map_err(|e| RegistryError::Io {
358            path: path.display().to_string(),
359            source: std::io::Error::other(e.to_string()),
360        })?;
361        Ok(if existed {
362            RegisterOutcome::Updated
363        } else {
364            RegisterOutcome::Added
365        })
366    }
367
368    /// Allow destructive actions on one registered device, once.
369    ///
370    /// Goes through [`Self::register`] rather than rewriting the file,
371    /// for the reason that function documents: a read-modify-write of the
372    /// whole registry loses a concurrent registration silently. One key
373    /// in, one key out.
374    ///
375    /// Returns the alias it was recorded against and whether it was
376    /// already allowed — the caller can then say "already allowed"
377    /// instead of implying something changed.
378    ///
379    /// # Errors
380    ///
381    /// [`RegistryError::UnknownDevice`] when nothing matches the ref. The
382    /// opt-in is per device, so there is nothing to record it against —
383    /// and silently creating an entry would mean allowing destruction on
384    /// a device nobody registered.
385    pub fn allow_destructive(
386        path: &Path,
387        device_ref: &str,
388    ) -> Result<(String, bool), RegistryError> {
389        let reg = Self::load(path)?;
390        let Some((alias, sim)) = reg
391            .sims()
392            .iter()
393            .find(|(alias, sim)| {
394                alias.as_str() == device_ref
395                    || sim.device_name == device_ref
396                    || sim.udid.eq_ignore_ascii_case(device_ref)
397            })
398            .map(|(a, s)| (a.clone(), s.clone()))
399        else {
400            let mut known: Vec<String> = Vec::new();
401            for (alias, sim) in reg.sims() {
402                known.push(alias.clone());
403                known.push(sim.device_name.clone());
404            }
405            return Err(RegistryError::UnknownDevice {
406                device_ref: device_ref.to_string(),
407                known,
408            });
409        };
410        if sim.destructive_opt_in {
411            return Ok((alias, true));
412        }
413        let updated = RegisteredSim {
414            destructive_opt_in: true,
415            ..sim
416        };
417        Self::register(path, &alias, updated)?;
418        Ok((alias, false))
419    }
420
421    /// Read every registered sim.
422    ///
423    /// `path` may be the `.smix` directory or a legacy `sims.json`
424    /// inside it; both land on the same store.
425    pub fn load(path: &Path) -> Result<Self, RegistryError> {
426        let store = open_store(path)?;
427        let mut sims = BTreeMap::new();
428        for alias in store.sims().list() {
429            let sim: RegisteredSim = store
430                .sims()
431                .get_json(&alias)
432                .map_err(|e| RegistryError::Malformed {
433                    path: path.display().to_string(),
434                    detail: e.to_string(),
435                })?
436                .ok_or_else(|| RegistryError::Malformed {
437                    path: path.display().to_string(),
438                    detail: format!("`{alias}` vanished between listing and reading"),
439                })?;
440            sims.insert(alias, sim);
441        }
442        Ok(Self { sims })
443    }
444
445    /// Walk up from `start` looking for a `.smix` that holds a
446    /// registry — either the store or a legacy `sims.json`.
447    pub fn discover(start: &Path) -> Option<PathBuf> {
448        let mut dir = Some(start);
449        while let Some(d) = dir {
450            let smix = d.join(".smix");
451            if smix.join("sims.json").is_file() || smix.join("kv").is_dir() {
452                return Some(smix);
453            }
454            dir = d.parent();
455        }
456        None
457    }
458
459    /// Resolve a device ref to the identifier its platform is addressed by.
460    ///
461    /// CoreSimulator-form input passes through whether or not it is
462    /// registered. Otherwise the ref must match an alias key, a
463    /// `deviceName`, or the registered identifier itself.
464    ///
465    /// That last one was missing until 2026-08-06, and [`Self::lookup`]
466    /// had it — so the two disagreed about whether a device's own
467    /// identifier names it. A real phone found the disagreement: an iOS
468    /// device UDID is 25 characters, not CoreSimulator's 36, so it fell
469    /// past the short-circuit into a search that never looked at the one
470    /// field it matched. `smix runner forward 00008120-…` answered
471    /// "unknown device ref" about a device that was registered right
472    /// there in the file it was reading.
473    pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
474        if is_udid(device_ref) {
475            return Ok(device_ref.to_ascii_uppercase());
476        }
477        // Stored verbatim, returned verbatim. The value was already put
478        // in its platform's form at registration by
479        // [`canonical_identifier`]; upper-casing it a second time here is
480        // what turned a registered `emulator-5554` into the
481        // `EMULATOR-5554` that adb does not answer to.
482        if let Some(sim) = self.sims.get(device_ref) {
483            return Ok(sim.udid.clone());
484        }
485        if let Some(sim) = self
486            .sims
487            .values()
488            .find(|s| s.device_name == device_ref || s.udid.eq_ignore_ascii_case(device_ref))
489        {
490            return Ok(sim.udid.clone());
491        }
492        // Deduplicated: an alias and a device name are commonly the same
493        // word, and "one of the recorded aliases: phone, phone" reads as
494        // a bug in the tool rather than a list of choices.
495        let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
496        for (alias, sim) in &self.sims {
497            for name in [alias, &sim.device_name] {
498                if !known.iter().any(|k| k == name) {
499                    known.push(name.clone());
500                }
501            }
502        }
503        Err(RegistryError::UnknownDevice {
504            device_ref: device_ref.to_string(),
505            known,
506        })
507    }
508
509    /// All registered sims, keyed by alias.
510    pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
511        &self.sims
512    }
513
514    /// Look up a [`RegisteredSim`] by alias key, device name, or UDID.
515    /// Returns `None` if no entry matches any of the three. Mirrors
516    /// [`Self::resolve`]'s match precedence so cli callers can fetch
517    /// the full spec (e.g. `locale` field) after they already resolved
518    /// the UDID.
519    pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
520        if let Some(sim) = self.sims.get(device_ref) {
521            return Some(sim);
522        }
523        self.sims
524            .values()
525            .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
526    }
527}
528
529#[cfg(test)]
530mod kind_tests {
531    use super::*;
532
533    const UDID: &str = "47ACEAE5-36BA-4C62-811B-F09B397910D7";
534
535    #[test]
536    fn each_virtual_kind_takes_its_own_platforms_identifiers() {
537        assert!(identifier_fits(DeviceKind::Simulator, UDID).is_ok());
538        assert!(identifier_fits(DeviceKind::Emulator, "emulator-5554").is_ok());
539        // And not each other's. A UDID registered as an emulator would
540        // be an alias for something adb can never be handed.
541        assert!(identifier_fits(DeviceKind::Simulator, "emulator-5554").is_err());
542        assert!(identifier_fits(DeviceKind::Emulator, UDID).is_err());
543    }
544
545    #[test]
546    fn a_physical_identifier_is_taken_as_given() {
547        // Nothing on this machine can enumerate the world's phones, so
548        // there is no catalogue to check against — which is precisely
549        // why registering one is a deliberate act. Both spellings are
550        // legitimate: a UDID for iOS, an adb serial for Android.
551        assert!(identifier_fits(DeviceKind::PhysicalIos, "00008120-001410C11A42201E").is_ok());
552        assert!(identifier_fits(DeviceKind::PhysicalAndroid, "R5CT52DF07D").is_ok());
553        // Empty is still nothing.
554        assert!(identifier_fits(DeviceKind::PhysicalIos, "   ").is_err());
555    }
556
557    #[test]
558    fn an_emulator_serial_is_recognised_not_guessed() {
559        assert!(is_emulator_serial("emulator-5554"));
560        assert!(!is_emulator_serial("emulator-"));
561        assert!(!is_emulator_serial("emulator-abcd"));
562        // Case matters: adb matches serials verbatim, and this is not a
563        // device. The UDID path upper-cases; this one must not.
564        assert!(!is_emulator_serial("EMULATOR-5554"));
565        assert!(!is_emulator_serial("R5CT52DF07D"));
566    }
567
568    #[test]
569    fn apple_identifiers_are_normalised_and_adb_serials_are_not() {
570        // Measured, not assumed: `devicectl` rejects the lower-case
571        // spelling of a UDID it accepts in upper-case, so upper-casing
572        // an Apple identifier rescues it. `adb` matches byte for byte,
573        // so the same move would break it.
574        assert_eq!(
575            canonical_identifier(
576                DeviceKind::Simulator,
577                "47aceae5-36ba-4c62-811b-f09b397910d7"
578            ),
579            "47ACEAE5-36BA-4C62-811B-F09B397910D7"
580        );
581        assert_eq!(
582            canonical_identifier(DeviceKind::PhysicalIos, "00008120-001410c11a42201e"),
583            "00008120-001410C11A42201E"
584        );
585        assert_eq!(
586            canonical_identifier(DeviceKind::Emulator, "emulator-5554"),
587            "emulator-5554"
588        );
589        assert_eq!(
590            canonical_identifier(DeviceKind::PhysicalAndroid, "abc123xyz"),
591            "abc123xyz"
592        );
593    }
594
595    #[test]
596    fn an_alias_resolves_to_what_was_stored_not_to_an_upper_cased_copy() {
597        // The bug this pins: `sim resolve` used to upper-case whatever
598        // it returned, so a registered `emulator-5554` came back as
599        // `EMULATOR-5554` — a string adb does not answer to. Normalising
600        // happens once, at registration, where the kind is known.
601        let mut sims = BTreeMap::new();
602        sims.insert(
603            "emu".to_string(),
604            RegisteredSim {
605                device_name: "emu".into(),
606                udid: "emulator-5554".into(),
607                runtime: String::new(),
608                device_type: String::new(),
609                locale: None,
610                runner_port: None,
611                kind: DeviceKind::Emulator,
612                destructive_opt_in: false,
613            },
614        );
615        let reg = SimRegistry { sims };
616        assert_eq!(reg.resolve("emu").unwrap(), "emulator-5554");
617    }
618
619    #[test]
620    fn the_mismatch_says_what_that_kind_looks_like() {
621        // "not UDID-form" told an Android user the shape of a thing they
622        // were not registering. The message has to name the world they
623        // are actually in.
624        let e = identifier_fits(DeviceKind::Emulator, UDID).expect_err("must refuse");
625        let msg = e.to_string();
626        assert!(msg.contains("emulator-<port>"), "got: {msg}");
627        assert!(msg.contains("adb devices"), "got: {msg}");
628        assert!(!msg.contains("8-4-4-4-12"), "wrong world named: {msg}");
629    }
630
631    #[test]
632    fn a_registry_written_before_this_field_reads_as_simulator() {
633        // The compatibility case that matters: every existing registry on
634        // every machine was written without `kind`. Reading those as
635        // physical would put a working simulator setup behind an opt-in
636        // its owner never asked for.
637        let json = r#"{
638            "deviceName": "sim-smix-02",
639            "udid": "5D087114-ECB3-443C-8DDB-40EEF9CFB90C",
640            "runtime": "iOS-26-5",
641            "deviceType": "iPhone-17-Pro"
642        }"#;
643        let sim: RegisteredSim = serde_json::from_str(json).expect("old record still parses");
644        assert_eq!(sim.kind, DeviceKind::Simulator);
645        assert!(!sim.kind.is_physical());
646        assert!(!sim.destructive_opt_in, "opt-in defaults to off");
647    }
648
649    #[test]
650    fn every_kind_knows_whether_it_is_physical() {
651        assert!(!DeviceKind::Simulator.is_physical());
652        assert!(!DeviceKind::Emulator.is_physical());
653        assert!(DeviceKind::PhysicalIos.is_physical());
654        assert!(DeviceKind::PhysicalAndroid.is_physical());
655    }
656
657    #[test]
658    fn kind_roundtrips_with_its_wire_spelling_pinned() {
659        let sim = RegisteredSim {
660            device_name: "panda".into(),
661            kind: DeviceKind::PhysicalIos,
662            destructive_opt_in: true,
663            udid: "00008120-001410C11A42201E".into(),
664            runtime: "iOS-26-5".into(),
665            device_type: "iPhone15,4".into(),
666            locale: None,
667            runner_port: None,
668        };
669        let json = serde_json::to_string(&sim).expect("serialize");
670        assert!(json.contains("\"physicalIos\""), "got: {json}");
671        assert!(json.contains("\"destructiveOptIn\":true"), "got: {json}");
672        let back: RegisteredSim = serde_json::from_str(&json).expect("deserialize");
673        assert_eq!(back.kind, DeviceKind::PhysicalIos);
674        assert!(back.destructive_opt_in);
675    }
676}