use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RegistryError {
#[error("cannot read sim registry {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("malformed sim registry {path}: {detail}")]
Malformed {
path: String,
detail: String,
},
#[error(
"unknown device ref {device_ref:?} — pass an explicit UDID or one of \
the recorded aliases: {}",
known.join(", ")
)]
UnknownDevice {
device_ref: String,
known: Vec<String>,
},
#[error("{}", diverged_message(alias, machine, checkouts))]
Diverged {
alias: String,
machine: (PathBuf, String),
checkouts: Vec<(PathBuf, String)>,
},
}
fn diverged_message(
alias: &str,
machine: &(PathBuf, String),
checkouts: &[(PathBuf, String)],
) -> String {
let others = checkouts
.iter()
.map(|(p, id)| format!("{} says {id}", p.display()))
.collect::<Vec<_>>()
.join("; ");
format!(
"`{alias}` names two devices: this machine's registry ({}) says {}, and {others}. \
A checkout's book is read, never written, and it does not get to decide which \
device an alias drives — so nothing was driven. Make them agree: correct that \
file (smix does not edit it), or record the device on this machine with \
`smix sim register --udid <id> {alias}`.",
machine.0.display(),
machine.1
)
}
#[derive(Deserialize)]
struct LegacyBook {
#[serde(default)]
sims: BTreeMap<String, RegisteredSim>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Divergence {
pub machine: (PathBuf, String),
pub checkouts: Vec<(PathBuf, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Machine(PathBuf),
Checkout(PathBuf),
Named(PathBuf),
Literal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub id: String,
pub alias: String,
pub source: Source,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DeviceKind {
#[default]
Simulator,
Emulator,
PhysicalIos,
PhysicalAndroid,
}
impl DeviceKind {
pub const ALL: [DeviceKind; 4] = [
DeviceKind::Simulator,
DeviceKind::Emulator,
DeviceKind::PhysicalIos,
DeviceKind::PhysicalAndroid,
];
#[must_use]
pub fn is_physical(self) -> bool {
match self {
DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => true,
DeviceKind::Simulator | DeviceKind::Emulator => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisteredSim {
#[serde(rename = "deviceName")]
pub device_name: String,
#[serde(default)]
pub kind: DeviceKind,
#[serde(default, rename = "destructiveOptIn")]
pub destructive_opt_in: bool,
pub udid: String,
pub runtime: String,
#[serde(rename = "deviceType")]
pub device_type: String,
#[serde(default, rename = "avdName", skip_serializing_if = "Option::is_none")]
pub avd_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(
default,
rename = "runnerPort",
skip_serializing_if = "Option::is_none"
)]
pub runner_port: Option<u16>,
}
impl RegisteredSim {
pub fn avd_name(&self) -> Option<&str> {
if self.kind != DeviceKind::Emulator {
return None;
}
self.avd_name
.as_deref()
.or(Some(self.device_type.as_str()))
.filter(|a| !a.is_empty())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegisterOutcome {
Added,
Updated,
}
#[derive(Debug, Default, Clone)]
pub struct SimRegistry {
sims: BTreeMap<String, RegisteredSim>,
}
#[derive(Debug, Default, Clone)]
pub struct MigrationReport {
pub added: Vec<String>,
pub unchanged: Vec<String>,
pub narrowed: Vec<String>,
pub empty: Vec<PathBuf>,
pub unreadable: Vec<(PathBuf, String)>,
}
#[derive(Debug, Default, Clone)]
pub struct MergedRegistry {
pub registry: SimRegistry,
pub unmigrated: BTreeMap<String, PathBuf>,
pub sources: BTreeMap<String, Source>,
pub diverged: BTreeMap<String, Divergence>,
}
impl MergedRegistry {
pub fn resolve_ref(&self, device_ref: &str) -> Result<Resolved, RegistryError> {
let alias = self.matched_alias(device_ref);
if let Some(a) = alias.as_deref().or(Some(device_ref))
&& let Some(d) = self.diverged.get(a)
{
return Err(RegistryError::Diverged {
alias: a.to_string(),
machine: d.machine.clone(),
checkouts: d.checkouts.clone(),
});
}
let id = self.registry.resolve(device_ref)?;
let (alias, source) = match alias {
Some(a) => {
let src = self.sources.get(&a).cloned().unwrap_or(Source::Literal);
(a, src)
}
None => (device_ref.to_string(), Source::Literal),
};
Ok(Resolved { id, alias, source })
}
fn matched_alias(&self, device_ref: &str) -> Option<String> {
let sims = self.registry.sims();
if sims.contains_key(device_ref) {
return Some(device_ref.to_string());
}
sims.iter()
.find(|(_, s)| s.device_name == device_ref || s.udid.eq_ignore_ascii_case(device_ref))
.map(|(a, _)| a.clone())
}
}
pub fn is_udid(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() != 36 {
return false;
}
for (i, b) in bytes.iter().enumerate() {
match i {
8 | 13 | 18 | 23 => {
if *b != b'-' {
return false;
}
}
_ => {
if !b.is_ascii_hexdigit() {
return false;
}
}
}
}
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmulatorAddress {
At {
serial: String,
},
MovedTo {
serial: String,
recorded: String,
},
NotRunning {
avd: String,
slot_now: Option<String>,
},
NoIdentityRecorded {
serial: String,
},
}
impl EmulatorAddress {
pub const VARIANTS: usize = 4;
}
pub fn emulator_address(
recorded_avd: Option<&str>,
recorded_serial: &str,
live: &[(String, String)],
) -> EmulatorAddress {
let Some(avd) = recorded_avd.filter(|a| !a.is_empty()) else {
return EmulatorAddress::NoIdentityRecorded {
serial: recorded_serial.to_string(),
};
};
if let Some((serial, _)) = live.iter().find(|(_, name)| name == avd) {
return if serial == recorded_serial {
EmulatorAddress::At {
serial: serial.clone(),
}
} else {
EmulatorAddress::MovedTo {
serial: serial.clone(),
recorded: recorded_serial.to_string(),
}
};
}
EmulatorAddress::NotRunning {
avd: avd.to_string(),
slot_now: live
.iter()
.find(|(serial, _)| serial == recorded_serial)
.map(|(_, name)| name.clone()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentifierMismatch {
pub given: String,
pub expected: &'static str,
}
impl std::fmt::Display for IdentifierMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:?} is not how that kind of device is identified — {}",
self.given, self.expected
)
}
}
pub fn identifier_fits(kind: DeviceKind, id: &str) -> Result<(), IdentifierMismatch> {
let ok = match kind {
DeviceKind::Simulator => is_udid(id),
DeviceKind::Emulator => is_emulator_serial(id),
DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => !id.trim().is_empty(),
};
if ok {
return Ok(());
}
Err(IdentifierMismatch {
given: id.to_string(),
expected: match kind {
DeviceKind::Simulator => {
"a simulator has a CoreSimulator UDID (8-4-4-4-12 hex); find it with `smix sim list`"
}
DeviceKind::Emulator => {
"adb names an emulator `emulator-<port>`, e.g. emulator-5554; find it with `adb devices`"
}
DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid => {
"a physical device needs a non-empty identifier: a UDID for iOS, an adb serial for Android"
}
},
})
}
#[must_use]
pub fn canonical_identifier(kind: DeviceKind, id: &str) -> String {
match kind {
DeviceKind::Simulator | DeviceKind::PhysicalIos => id.to_ascii_uppercase(),
DeviceKind::Emulator | DeviceKind::PhysicalAndroid => id.to_string(),
}
}
#[must_use]
pub fn is_emulator_serial(s: &str) -> bool {
s.strip_prefix("emulator-")
.is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
}
pub fn store_dir(path: &Path) -> PathBuf {
smix_dir(path)
}
fn smix_dir(path: &Path) -> PathBuf {
if path.extension().is_some_and(|e| e == "json") {
path.parent().unwrap_or(path).to_path_buf()
} else {
path.to_path_buf()
}
}
fn open_store(path: &Path) -> Result<smix_store::Store, RegistryError> {
let dir = smix_dir(path);
std::fs::create_dir_all(&dir).map_err(|source| RegistryError::Io {
path: dir.display().to_string(),
source,
})?;
let store = smix_store::Store::open(&dir).map_err(|e| RegistryError::Io {
path: dir.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
let legacy = dir.join("sims.json");
smix_store::import_legacy_records(&store.sims(), &legacy, "sims").map_err(|e| {
RegistryError::Malformed {
path: legacy.display().to_string(),
detail: e.to_string(),
}
})?;
Ok(store)
}
impl SimRegistry {
pub fn register(
path: &Path,
alias: &str,
sim: RegisteredSim,
) -> Result<RegisterOutcome, RegistryError> {
let store = open_store(path)?;
let existed = store
.sims()
.get(alias)
.map_err(|e| RegistryError::Malformed {
path: path.display().to_string(),
detail: e.to_string(),
})?
.is_some();
store
.sims()
.put_json(alias, &sim)
.map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
store.sync().map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
Ok(if existed {
RegisterOutcome::Updated
} else {
RegisterOutcome::Added
})
}
pub fn set_project_alias(
path: &Path,
project_key: &str,
alias: &str,
) -> Result<(), RegistryError> {
let store = open_store(path)?;
store
.project_devices()
.put(project_key, alias.as_bytes())
.map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
store.sync().map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})
}
pub fn project_alias(path: &Path, project_key: &str) -> Result<Option<String>, RegistryError> {
let store = open_store(path)?;
let raw = store
.project_devices()
.get(project_key)
.map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
Ok(raw.map(|bytes| String::from_utf8_lossy(&bytes).into_owned()))
}
pub fn unregister(path: &Path, alias: &str) -> Result<RegisteredSim, RegistryError> {
let reg = Self::load(path)?;
let Some(sim) = reg.sims.get(alias).cloned() else {
return Err(RegistryError::UnknownDevice {
device_ref: alias.to_string(),
known: reg.sims.keys().cloned().collect(),
});
};
let store = open_store(path)?;
store.sims().delete(alias).map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
store.sync().map_err(|e| RegistryError::Io {
path: path.display().to_string(),
source: std::io::Error::other(e.to_string()),
})?;
Ok(sim)
}
pub fn allow_destructive(
path: &Path,
device_ref: &str,
) -> Result<(String, bool), RegistryError> {
let reg = Self::load(path)?;
let Some((alias, sim)) = reg
.sims()
.iter()
.find(|(alias, sim)| {
alias.as_str() == device_ref
|| sim.device_name == device_ref
|| sim.udid.eq_ignore_ascii_case(device_ref)
})
.map(|(a, s)| (a.clone(), s.clone()))
else {
let mut known: Vec<String> = Vec::new();
for (alias, sim) in reg.sims() {
known.push(alias.clone());
known.push(sim.device_name.clone());
}
return Err(RegistryError::UnknownDevice {
device_ref: device_ref.to_string(),
known,
});
};
if sim.destructive_opt_in {
return Ok((alias, true));
}
let updated = RegisteredSim {
destructive_opt_in: true,
..sim
};
Self::register(path, &alias, updated)?;
Ok((alias, false))
}
pub fn load(path: &Path) -> Result<Self, RegistryError> {
let store = open_store(path)?;
let mut sims = BTreeMap::new();
for alias in store.sims().list() {
let sim: RegisteredSim = store
.sims()
.get_json(&alias)
.map_err(|e| RegistryError::Malformed {
path: path.display().to_string(),
detail: e.to_string(),
})?
.ok_or_else(|| RegistryError::Malformed {
path: path.display().to_string(),
detail: format!("`{alias}` vanished between listing and reading"),
})?;
sims.insert(alias, sim);
}
Ok(Self { sims })
}
pub fn machine_dir() -> Option<PathBuf> {
smix_lease::store::machine_root().map(|r| r.join("devices"))
}
pub fn read_paths(start: &Path) -> Vec<PathBuf> {
if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
return vec![PathBuf::from(p)];
}
let mut paths = Vec::new();
if let Some(m) = Self::machine_dir() {
paths.push(m);
}
if let Some(c) = Self::discover(start) {
paths.push(c);
}
paths
}
pub fn open_all(start: &Path) -> MergedRegistry {
if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
let path = PathBuf::from(p);
let registry = Self::load(&path).unwrap_or_default();
let sources = registry
.sims
.keys()
.map(|a| (a.clone(), Source::Named(path.clone())))
.collect();
return MergedRegistry {
registry,
unmigrated: BTreeMap::new(),
sources,
diverged: BTreeMap::new(),
};
}
let machine = Self::machine_dir();
let checkouts: Vec<PathBuf> = Self::discover(start).into_iter().collect();
Self::open_books(machine.as_deref(), &checkouts)
}
pub fn open_books(machine: Option<&Path>, checkouts: &[PathBuf]) -> MergedRegistry {
let machine = machine.and_then(|m| Self::load(m).ok().map(|r| (m.to_path_buf(), r)));
let mut books: Vec<(PathBuf, Self)> = checkouts
.iter()
.flat_map(|c| Self::checkout_books(c))
.collect();
books.sort_by(|a, b| a.0.cmp(&b.0));
Self::combine(machine, books)
}
fn checkout_books(path: &Path) -> Vec<(PathBuf, Self)> {
let dir = smix_dir(path);
let mut out = Vec::new();
if dir.join("kv").is_dir()
&& let Ok(store) = smix_store::Store::open(&dir)
{
let mut sims = BTreeMap::new();
for alias in store.sims().list() {
if let Ok(Some(sim)) = store.sims().get_json::<RegisteredSim>(&alias) {
sims.insert(alias, sim);
}
}
out.push((dir.clone(), Self { sims }));
}
let legacy = dir.join("sims.json");
if let Ok(bytes) = std::fs::read(&legacy)
&& let Ok(doc) = serde_json::from_slice::<LegacyBook>(&bytes)
{
out.push((legacy, Self { sims: doc.sims }));
}
out
}
fn combine(machine: Option<(PathBuf, Self)>, books: Vec<(PathBuf, Self)>) -> MergedRegistry {
let (mpath, mut mreg) = match machine {
Some((p, r)) => (Some(p), r),
None => (None, Self::default()),
};
let mut sources: BTreeMap<String, Source> = BTreeMap::new();
if let Some(p) = &mpath {
for alias in mreg.sims.keys() {
sources.insert(alias.clone(), Source::Machine(p.clone()));
}
}
let mut diverged: BTreeMap<String, Divergence> = BTreeMap::new();
let mut rest: Vec<Self> = Vec::new();
let mut rest_paths: Vec<(PathBuf, Self)> = Vec::new();
for (path, book) in books {
let mut kept = BTreeMap::new();
for (alias, sim) in book.sims {
match (mreg.sims.get_mut(&alias), &mpath) {
(Some(own), _) if own.udid.eq_ignore_ascii_case(&sim.udid) => {
own.destructive_opt_in = own.destructive_opt_in && sim.destructive_opt_in;
}
(Some(own), Some(mp)) => {
diverged
.entry(alias)
.or_insert_with(|| Divergence {
machine: (mp.clone(), own.udid.clone()),
checkouts: Vec::new(),
})
.checkouts
.push((path.clone(), sim.udid));
}
_ => {
kept.insert(alias, sim);
}
}
}
rest_paths.push((path.clone(), Self { sims: kept.clone() }));
rest.push(Self { sims: kept });
}
let checkout_only = Self::merge(rest);
let on_machine: std::collections::BTreeSet<String> = mreg
.sims
.values()
.map(|s| s.udid.to_ascii_uppercase())
.collect();
let mut unmigrated = BTreeMap::new();
for (alias, sim) in checkout_only.sims {
let from = rest_paths
.iter()
.find(|(_, b)| {
b.sims
.values()
.any(|s| s.udid.eq_ignore_ascii_case(&sim.udid))
})
.map(|(p, _)| p.clone())
.unwrap_or_default();
if mpath.is_some() && !on_machine.contains(&sim.udid.to_ascii_uppercase()) {
unmigrated.insert(alias.clone(), from.clone());
}
sources.insert(alias.clone(), Source::Checkout(from));
mreg.sims.insert(alias, sim);
}
MergedRegistry {
registry: mreg,
unmigrated,
sources,
diverged,
}
}
pub fn discover(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
let smix = d.join(".smix");
if smix.join("sims.json").is_file() || smix.join("kv").is_dir() {
return Some(smix);
}
dir = d.parent();
}
None
}
pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
if is_udid(device_ref) {
return Ok(device_ref.to_ascii_uppercase());
}
if let Some(sim) = self.sims.get(device_ref) {
return Ok(sim.udid.clone());
}
if let Some(sim) = self
.sims
.values()
.find(|s| s.device_name == device_ref || s.udid.eq_ignore_ascii_case(device_ref))
{
return Ok(sim.udid.clone());
}
let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
for (alias, sim) in &self.sims {
for name in [alias, &sim.device_name] {
if !known.iter().any(|k| k == name) {
known.push(name.clone());
}
}
}
Err(RegistryError::UnknownDevice {
device_ref: device_ref.to_string(),
known,
})
}
pub fn merge(sources: impl IntoIterator<Item = Self>) -> Self {
let mut out: BTreeMap<String, RegisteredSim> = BTreeMap::new();
let mut incoming: Vec<(String, RegisteredSim)> = sources
.into_iter()
.flat_map(|r| r.sims.into_iter())
.collect();
incoming.sort_by(|a, b| (&a.0, &a.1.udid).cmp(&(&b.0, &b.1.udid)));
for (alias, sim) in incoming {
match out.get_mut(&alias) {
None => {
out.insert(alias, sim);
}
Some(existing) if existing.udid == sim.udid => {
existing.destructive_opt_in =
existing.destructive_opt_in && sim.destructive_opt_in;
}
Some(_) => {
let short = sim.udid.chars().take(8).collect::<String>().to_lowercase();
out.insert(format!("{alias}-{short}"), sim);
}
}
}
Self { sims: out }
}
pub fn migrate(into: &Path, sources: &[PathBuf]) -> Result<MigrationReport, RegistryError> {
Self::migrate_inner(into, sources, true)
}
pub fn migrate_dry_run(
into: &Path,
sources: &[PathBuf],
) -> Result<MigrationReport, RegistryError> {
Self::migrate_inner(into, sources, false)
}
fn migrate_inner(
into: &Path,
sources: &[PathBuf],
commit: bool,
) -> Result<MigrationReport, RegistryError> {
let mut report = MigrationReport::default();
let mut books = vec![Self::load(into)?];
let before: BTreeMap<String, String> = books[0]
.sims
.iter()
.map(|(a, s)| (a.clone(), s.udid.clone()))
.collect();
for src in sources {
match Self::load(src) {
Ok(reg) if reg.sims.is_empty() => report.empty.push(src.clone()),
Ok(reg) => books.push(reg),
Err(e) => report.unreadable.push((src.clone(), e.to_string())),
}
}
let merged = Self::merge(books);
for (alias, sim) in &merged.sims {
match before.get(alias) {
Some(udid) if udid == &sim.udid => report.unchanged.push(alias.clone()),
Some(_) => report.narrowed.push(alias.clone()),
None => report.added.push(alias.clone()),
}
if commit {
Self::register(into, alias, sim.clone())?;
}
}
Ok(report)
}
pub fn all(&self) -> impl Iterator<Item = (&str, &RegisteredSim)> {
self.sims.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn insert(&mut self, alias: impl Into<String>, sim: RegisteredSim) {
self.sims.insert(alias.into(), sim);
}
pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
&self.sims
}
pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
if let Some(sim) = self.sims.get(device_ref) {
return Some(sim);
}
self.sims
.values()
.find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
}
}
#[cfg(test)]
mod kind_tests {
use super::*;
const UDID: &str = "47ACEAE5-36BA-4C62-811B-F09B397910D7";
#[test]
fn each_virtual_kind_takes_its_own_platforms_identifiers() {
assert!(identifier_fits(DeviceKind::Simulator, UDID).is_ok());
assert!(identifier_fits(DeviceKind::Emulator, "emulator-5554").is_ok());
assert!(identifier_fits(DeviceKind::Simulator, "emulator-5554").is_err());
assert!(identifier_fits(DeviceKind::Emulator, UDID).is_err());
}
#[test]
fn a_physical_identifier_is_taken_as_given() {
assert!(identifier_fits(DeviceKind::PhysicalIos, "00008120-001410C11A42201E").is_ok());
assert!(identifier_fits(DeviceKind::PhysicalAndroid, "R5CT52DF07D").is_ok());
assert!(identifier_fits(DeviceKind::PhysicalIos, " ").is_err());
}
#[test]
fn an_emulator_serial_is_recognised_not_guessed() {
assert!(is_emulator_serial("emulator-5554"));
assert!(!is_emulator_serial("emulator-"));
assert!(!is_emulator_serial("emulator-abcd"));
assert!(!is_emulator_serial("EMULATOR-5554"));
assert!(!is_emulator_serial("R5CT52DF07D"));
}
#[test]
fn apple_identifiers_are_normalised_and_adb_serials_are_not() {
assert_eq!(
canonical_identifier(
DeviceKind::Simulator,
"47aceae5-36ba-4c62-811b-f09b397910d7"
),
"47ACEAE5-36BA-4C62-811B-F09B397910D7"
);
assert_eq!(
canonical_identifier(DeviceKind::PhysicalIos, "00008120-001410c11a42201e"),
"00008120-001410C11A42201E"
);
assert_eq!(
canonical_identifier(DeviceKind::Emulator, "emulator-5554"),
"emulator-5554"
);
assert_eq!(
canonical_identifier(DeviceKind::PhysicalAndroid, "abc123xyz"),
"abc123xyz"
);
}
#[test]
fn an_alias_resolves_to_what_was_stored_not_to_an_upper_cased_copy() {
let mut sims = BTreeMap::new();
sims.insert(
"emu".to_string(),
RegisteredSim {
device_name: "emu".into(),
udid: "emulator-5554".into(),
runtime: String::new(),
device_type: String::new(),
avd_name: None,
locale: None,
runner_port: None,
kind: DeviceKind::Emulator,
destructive_opt_in: false,
},
);
let reg = SimRegistry { sims };
assert_eq!(reg.resolve("emu").unwrap(), "emulator-5554");
}
#[test]
fn the_mismatch_says_what_that_kind_looks_like() {
let e = identifier_fits(DeviceKind::Emulator, UDID).expect_err("must refuse");
let msg = e.to_string();
assert!(msg.contains("emulator-<port>"), "got: {msg}");
assert!(msg.contains("adb devices"), "got: {msg}");
assert!(!msg.contains("8-4-4-4-12"), "wrong world named: {msg}");
}
#[test]
fn a_registry_written_before_this_field_reads_as_simulator() {
let json = r#"{
"deviceName": "sim-smix-02",
"udid": "5D087114-ECB3-443C-8DDB-40EEF9CFB90C",
"runtime": "iOS-26-5",
"deviceType": "iPhone-17-Pro"
}"#;
let sim: RegisteredSim = serde_json::from_str(json).expect("old record still parses");
assert_eq!(sim.kind, DeviceKind::Simulator);
assert!(!sim.kind.is_physical());
assert!(!sim.destructive_opt_in, "opt-in defaults to off");
}
#[test]
fn every_kind_knows_whether_it_is_physical() {
assert!(!DeviceKind::Simulator.is_physical());
assert!(!DeviceKind::Emulator.is_physical());
assert!(DeviceKind::PhysicalIos.is_physical());
assert!(DeviceKind::PhysicalAndroid.is_physical());
}
#[test]
fn kind_roundtrips_with_its_wire_spelling_pinned() {
let sim = RegisteredSim {
device_name: "panda".into(),
kind: DeviceKind::PhysicalIos,
destructive_opt_in: true,
udid: "00008120-001410C11A42201E".into(),
runtime: "iOS-26-5".into(),
device_type: "iPhone15,4".into(),
avd_name: None,
locale: None,
runner_port: None,
};
let json = serde_json::to_string(&sim).expect("serialize");
assert!(json.contains("\"physicalIos\""), "got: {json}");
assert!(json.contains("\"destructiveOptIn\":true"), "got: {json}");
let back: RegisteredSim = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.kind, DeviceKind::PhysicalIos);
assert!(back.destructive_opt_in);
}
}
#[cfg(test)]
mod emulator_address_tests {
use super::*;
fn live(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(s, a)| ((*s).to_string(), (*a).to_string()))
.collect()
}
#[test]
fn an_avd_still_in_the_slot_it_was_registered_on_is_addressed_there() {
let out = emulator_address(
Some("a-01"),
"emulator-5554",
&live(&[("emulator-5554", "a-01")]),
);
assert_eq!(
out,
EmulatorAddress::At {
serial: "emulator-5554".into()
}
);
}
#[test]
fn an_avd_that_moved_slots_is_followed_not_looked_up_by_slot() {
let out = emulator_address(
Some("a-01"),
"emulator-5554",
&live(&[
("emulator-5554", "qip-consumer-36"),
("emulator-5556", "a-01"),
]),
);
assert_eq!(
out,
EmulatorAddress::MovedTo {
serial: "emulator-5556".into(),
recorded: "emulator-5554".into()
}
);
}
#[test]
fn an_avd_that_is_not_running_says_who_holds_the_slot_it_used_to_have() {
let out = emulator_address(
Some("a-01"),
"emulator-5554",
&live(&[("emulator-5554", "qip-consumer-36")]),
);
assert_eq!(
out,
EmulatorAddress::NotRunning {
avd: "a-01".into(),
slot_now: Some("qip-consumer-36".into())
}
);
}
#[test]
fn nothing_running_is_not_running_with_nobody_in_the_slot() {
let out = emulator_address(Some("a-01"), "emulator-5554", &live(&[]));
assert_eq!(
out,
EmulatorAddress::NotRunning {
avd: "a-01".into(),
slot_now: None
}
);
}
#[test]
fn a_row_with_no_identity_says_so_whatever_is_in_the_slot() {
let expected = EmulatorAddress::NoIdentityRecorded {
serial: "emulator-5554".into(),
};
assert_eq!(
emulator_address(None, "emulator-5554", &live(&[])),
expected
);
assert_eq!(
emulator_address(
None,
"emulator-5554",
&live(&[("emulator-5554", "somebody-else")])
),
expected
);
assert_eq!(
emulator_address(Some(""), "emulator-5554", &live(&[])),
expected
);
}
#[test]
fn avd_names_are_matched_byte_for_byte() {
let out = emulator_address(
Some("A-01"),
"emulator-5554",
&live(&[("emulator-5554", "a-01")]),
);
assert_eq!(
out,
EmulatorAddress::NotRunning {
avd: "A-01".into(),
slot_now: Some("a-01".into())
}
);
}
#[test]
fn there_are_four_answers_and_each_carries_a_different_sentence() {
assert_eq!(EmulatorAddress::VARIANTS, 4);
}
}
#[cfg(test)]
mod avd_name_tests {
use super::*;
fn row(kind: DeviceKind, device_type: &str, avd_name: Option<&str>) -> RegisteredSim {
RegisteredSim {
device_name: "d".into(),
kind,
destructive_opt_in: false,
udid: "emulator-5554".into(),
runtime: String::new(),
device_type: device_type.into(),
avd_name: avd_name.map(str::to_string),
locale: None,
runner_port: None,
}
}
#[test]
fn the_new_field_is_absent_from_the_wire_until_it_is_set() {
let json = serde_json::to_string(&row(DeviceKind::Emulator, "", None)).expect("serialize");
assert!(!json.contains("avdName"), "got: {json}");
let json =
serde_json::to_string(&row(DeviceKind::Emulator, "", Some("a-01"))).expect("serialize");
assert!(json.contains("\"avdName\":\"a-01\""), "got: {json}");
let back: RegisteredSim = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.avd_name(), Some("a-01"));
}
#[test]
fn an_emulator_row_written_before_the_field_existed_keeps_its_identity() {
let sim = row(DeviceKind::Emulator, "sim-smix-android-01", None);
assert_eq!(sim.avd_name(), Some("sim-smix-android-01"));
}
#[test]
fn a_simulators_device_type_is_not_an_avd_name() {
let sim = row(
DeviceKind::Simulator,
"com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro",
None,
);
assert_eq!(sim.avd_name(), None);
let phone = row(DeviceKind::PhysicalAndroid, "SM-S9010", None);
assert_eq!(phone.avd_name(), None);
}
#[test]
fn the_new_field_wins_when_both_are_there() {
let sim = row(DeviceKind::Emulator, "old-name", Some("new-name"));
assert_eq!(sim.avd_name(), Some("new-name"));
}
}