smix_simctl/registry.rs
1//! `.smix/sims.json` device registry — deterministic device addressing.
2//!
3//! Every smix device operation targets either an explicit UDID or an
4//! alias recorded in this file. Resolution never consults the live
5//! simulator set: the registry file is the only mapping source, so a
6//! given input always resolves to the same device regardless of what
7//! happens to be booted on the machine.
8
9use serde::Deserialize;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use thiserror::Error;
13
14/// Failure variants for registry load / device-ref resolution.
15#[derive(Debug, Error)]
16pub enum RegistryError {
17 /// Registry file could not be read.
18 #[error("cannot read sim registry {path}: {source}")]
19 Io {
20 /// Path that failed to read.
21 path: String,
22 /// Underlying I/O error.
23 source: std::io::Error,
24 },
25 /// Registry file is not valid registry JSON.
26 #[error("malformed sim registry {path}: {detail}")]
27 Malformed {
28 /// Path that failed to parse.
29 path: String,
30 /// Parser-side detail.
31 detail: String,
32 },
33 /// Input is neither a UDID nor a recorded alias.
34 #[error(
35 "unknown device ref {device_ref:?} — pass an explicit UDID or one of \
36 the recorded aliases: {}",
37 known.join(", ")
38 )]
39 UnknownDevice {
40 /// The input that failed to resolve.
41 device_ref: String,
42 /// Alias keys and device names available in the registry.
43 known: Vec<String>,
44 },
45}
46
47/// One registered simulator (a row of `.smix/sims.json`).
48#[derive(Debug, Clone, Deserialize)]
49pub struct RegisteredSim {
50 /// Human-chosen device name (also usable as an alias).
51 #[serde(rename = "deviceName")]
52 pub device_name: String,
53 /// CoreSimulator UDID.
54 pub udid: String,
55 /// Runtime identifier.
56 pub runtime: String,
57 /// Device type identifier.
58 #[serde(rename = "deviceType")]
59 pub device_type: String,
60 /// Desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`). When set,
61 /// `smix sim boot` enforces it via
62 /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
63 /// sim if the current locale differs. `None` (field absent) =
64 /// honor whatever locale the sim boots with, no enforcement.
65 #[serde(default)]
66 pub locale: Option<String>,
67 /// Desired runner port (SmixRunner FlyingFox HTTP port). When set,
68 /// `smix runner up <alias>` binds the runner to this port instead
69 /// of the CLI default 22087. Two sims can then run their own runner
70 /// in parallel without port collision
71 /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
72 /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
73 /// when absent.
74 #[serde(default, rename = "runnerPort")]
75 pub runner_port: Option<u16>,
76}
77
78#[derive(Debug, Deserialize)]
79struct RegistryFile {
80 #[allow(dead_code)]
81 version: u32,
82 sims: BTreeMap<String, RegisteredSim>,
83}
84
85/// Loaded view of `.smix/sims.json`, keyed by alias.
86#[derive(Debug)]
87pub struct SimRegistry {
88 sims: BTreeMap<String, RegisteredSim>,
89}
90
91/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
92///
93/// UDID-form input is always treated as a deliberate instruction and
94/// never requires registry membership.
95pub fn is_udid(s: &str) -> bool {
96 let bytes = s.as_bytes();
97 if bytes.len() != 36 {
98 return false;
99 }
100 for (i, b) in bytes.iter().enumerate() {
101 match i {
102 8 | 13 | 18 | 23 => {
103 if *b != b'-' {
104 return false;
105 }
106 }
107 _ => {
108 if !b.is_ascii_hexdigit() {
109 return false;
110 }
111 }
112 }
113 }
114 true
115}
116
117impl SimRegistry {
118 /// Parse the registry file at `path`.
119 pub fn load(path: &Path) -> Result<Self, RegistryError> {
120 let text = std::fs::read_to_string(path).map_err(|source| RegistryError::Io {
121 path: path.display().to_string(),
122 source,
123 })?;
124 let file: RegistryFile =
125 serde_json::from_str(&text).map_err(|e| RegistryError::Malformed {
126 path: path.display().to_string(),
127 detail: e.to_string(),
128 })?;
129 Ok(Self { sims: file.sims })
130 }
131
132 /// Walk up from `start` looking for `.smix/sims.json`.
133 pub fn discover(start: &Path) -> Option<PathBuf> {
134 let mut dir = Some(start);
135 while let Some(d) = dir {
136 let candidate = d.join(".smix/sims.json");
137 if candidate.is_file() {
138 return Some(candidate);
139 }
140 dir = d.parent();
141 }
142 None
143 }
144
145 /// Resolve a device ref to a UDID.
146 ///
147 /// An explicit UDID passes through (normalized to uppercase) whether
148 /// or not it is registered — UDID-form input is always a deliberate
149 /// instruction. Otherwise the ref must match an alias key or a
150 /// `deviceName` exactly; anything else errors listing what exists.
151 pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
152 if is_udid(device_ref) {
153 return Ok(device_ref.to_ascii_uppercase());
154 }
155 if let Some(sim) = self.sims.get(device_ref) {
156 return Ok(sim.udid.to_ascii_uppercase());
157 }
158 if let Some(sim) = self.sims.values().find(|s| s.device_name == device_ref) {
159 return Ok(sim.udid.to_ascii_uppercase());
160 }
161 let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
162 for (alias, sim) in &self.sims {
163 known.push(alias.clone());
164 known.push(sim.device_name.clone());
165 }
166 Err(RegistryError::UnknownDevice {
167 device_ref: device_ref.to_string(),
168 known,
169 })
170 }
171
172 /// All registered sims, keyed by alias.
173 pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
174 &self.sims
175 }
176
177 /// Look up a [`RegisteredSim`] by alias key, device name, or UDID.
178 /// Returns `None` if no entry matches any of the three. Mirrors
179 /// [`Self::resolve`]'s match precedence so cli callers can fetch
180 /// the full spec (e.g. `locale` field) after they already resolved
181 /// the UDID.
182 pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
183 if let Some(sim) = self.sims.get(device_ref) {
184 return Some(sim);
185 }
186 self.sims
187 .values()
188 .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
189 }
190}