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>,
},
}
#[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 {
matches!(self, DeviceKind::PhysicalIos | DeviceKind::PhysicalAndroid)
}
}
#[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, 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>,
}
#[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 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 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 {
let paths = Self::read_paths(start);
let machine = if std::env::var_os("SMIX_SIMS_JSON").is_some() {
None
} else {
Self::machine_dir()
};
let mut loaded: Vec<Self> = Vec::new();
let mut unmigrated: BTreeMap<String, PathBuf> = BTreeMap::new();
let mut on_machine: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for path in &paths {
let Ok(reg) = Self::load(path) else {
continue;
};
if machine.as_deref() == Some(path.as_path()) {
on_machine.extend(reg.sims.values().map(|s| s.udid.to_ascii_uppercase()));
} else if machine.is_some() {
for (alias, sim) in ®.sims {
if !on_machine.contains(&sim.udid.to_ascii_uppercase()) {
unmigrated.insert(alias.clone(), path.clone());
}
}
}
loaded.push(reg);
}
MergedRegistry {
registry: Self::merge(loaded),
unmigrated,
}
}
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(),
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(),
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);
}
}