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/// One registered simulator.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RegisteredSim {
54    /// Human-chosen device name (also usable as an alias).
55    #[serde(rename = "deviceName")]
56    pub device_name: String,
57    /// CoreSimulator UDID.
58    pub udid: String,
59    /// Runtime identifier.
60    pub runtime: String,
61    /// Device type identifier.
62    #[serde(rename = "deviceType")]
63    pub device_type: String,
64    /// Desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`). When set,
65    /// `smix sim boot` enforces it via
66    /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
67    /// sim if the current locale differs. `None` (field absent) =
68    /// honor whatever locale the sim boots with, no enforcement.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub locale: Option<String>,
71    /// Desired runner port (SmixRunner FlyingFox HTTP port). When set,
72    /// `smix runner up <alias>` binds the runner to this port instead
73    /// of the CLI default 22087. Two sims can then run their own runner
74    /// in parallel without port collision
75    /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
76    /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
77    /// when absent.
78    #[serde(
79        default,
80        rename = "runnerPort",
81        skip_serializing_if = "Option::is_none"
82    )]
83    pub runner_port: Option<u16>,
84}
85
86/// What [`SimRegistry::register`] did with the alias.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RegisterOutcome {
89    /// The alias did not exist; a new row was written.
90    Added,
91    /// The alias existed; its row was replaced.
92    Updated,
93}
94
95/// Loaded view of the registry, keyed by alias.
96#[derive(Debug)]
97pub struct SimRegistry {
98    sims: BTreeMap<String, RegisteredSim>,
99}
100
101/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
102///
103/// UDID-form input is always treated as a deliberate instruction and
104/// never requires registry membership.
105pub fn is_udid(s: &str) -> bool {
106    let bytes = s.as_bytes();
107    if bytes.len() != 36 {
108        return false;
109    }
110    for (i, b) in bytes.iter().enumerate() {
111        match i {
112            8 | 13 | 18 | 23 => {
113                if *b != b'-' {
114                    return false;
115                }
116            }
117            _ => {
118                if !b.is_ascii_hexdigit() {
119                    return false;
120                }
121            }
122        }
123    }
124    true
125}
126
127/// Resolve a caller-supplied path to the `.smix` directory holding the
128/// store.
129///
130/// Callers pass either form: `SMIX_SIMS_JSON` documents a file, while
131/// discovery yields a directory. Accepting both is also what lets the
132/// pre-store test suite keep exercising the legacy import path
133/// unchanged, instead of being rewritten into agreement with the code
134/// it is supposed to check.
135pub fn store_dir(path: &Path) -> PathBuf {
136    smix_dir(path)
137}
138
139fn smix_dir(path: &Path) -> PathBuf {
140    if path.extension().is_some_and(|e| e == "json") {
141        path.parent().unwrap_or(path).to_path_buf()
142    } else {
143        path.to_path_buf()
144    }
145}
146
147/// Open the store under `path` and fold in any legacy `sims.json`
148/// sitting beside it.
149///
150/// The legacy file is read, never written and never removed: a user who
151/// has to go back to a pre-store smix must still find their registry.
152fn open_store(path: &Path) -> Result<smix_store::Store, RegistryError> {
153    let dir = smix_dir(path);
154    std::fs::create_dir_all(&dir).map_err(|source| RegistryError::Io {
155        path: dir.display().to_string(),
156        source,
157    })?;
158    let store = smix_store::Store::open(&dir).map_err(|e| RegistryError::Io {
159        path: dir.display().to_string(),
160        source: std::io::Error::other(e.to_string()),
161    })?;
162    let legacy = dir.join("sims.json");
163    smix_store::import_legacy_records(&store.sims(), &legacy, "sims").map_err(|e| {
164        RegistryError::Malformed {
165            path: legacy.display().to_string(),
166            detail: e.to_string(),
167        }
168    })?;
169    Ok(store)
170}
171
172impl SimRegistry {
173    /// Write `sim` into the registry under `alias`.
174    ///
175    /// One key, not a whole file. The read-modify-write this replaces
176    /// lost an alias whenever two processes registered at once — each
177    /// read the file, each inserted its own row, and the second write
178    /// erased the first, with no error on either side.
179    pub fn register(
180        path: &Path,
181        alias: &str,
182        sim: RegisteredSim,
183    ) -> Result<RegisterOutcome, RegistryError> {
184        let store = open_store(path)?;
185        let existed = store
186            .sims()
187            .get(alias)
188            .map_err(|e| RegistryError::Malformed {
189                path: path.display().to_string(),
190                detail: e.to_string(),
191            })?
192            .is_some();
193        store
194            .sims()
195            .put_json(alias, &sim)
196            .map_err(|e| RegistryError::Io {
197                path: path.display().to_string(),
198                source: std::io::Error::other(e.to_string()),
199            })?;
200        store.sync().map_err(|e| RegistryError::Io {
201            path: path.display().to_string(),
202            source: std::io::Error::other(e.to_string()),
203        })?;
204        Ok(if existed {
205            RegisterOutcome::Updated
206        } else {
207            RegisterOutcome::Added
208        })
209    }
210
211    /// Read every registered sim.
212    ///
213    /// `path` may be the `.smix` directory or a legacy `sims.json`
214    /// inside it; both land on the same store.
215    pub fn load(path: &Path) -> Result<Self, RegistryError> {
216        let store = open_store(path)?;
217        let mut sims = BTreeMap::new();
218        for alias in store.sims().list() {
219            let sim: RegisteredSim = store
220                .sims()
221                .get_json(&alias)
222                .map_err(|e| RegistryError::Malformed {
223                    path: path.display().to_string(),
224                    detail: e.to_string(),
225                })?
226                .ok_or_else(|| RegistryError::Malformed {
227                    path: path.display().to_string(),
228                    detail: format!("`{alias}` vanished between listing and reading"),
229                })?;
230            sims.insert(alias, sim);
231        }
232        Ok(Self { sims })
233    }
234
235    /// Walk up from `start` looking for a `.smix` that holds a
236    /// registry — either the store or a legacy `sims.json`.
237    pub fn discover(start: &Path) -> Option<PathBuf> {
238        let mut dir = Some(start);
239        while let Some(d) = dir {
240            let smix = d.join(".smix");
241            if smix.join("sims.json").is_file() || smix.join("kv").is_dir() {
242                return Some(smix);
243            }
244            dir = d.parent();
245        }
246        None
247    }
248
249    /// Resolve a device ref to a UDID.
250    ///
251    /// An explicit UDID passes through (normalized to uppercase) whether
252    /// or not it is registered — UDID-form input is always a deliberate
253    /// instruction. Otherwise the ref must match an alias key or a
254    /// `deviceName` exactly; anything else errors listing what exists.
255    pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
256        if is_udid(device_ref) {
257            return Ok(device_ref.to_ascii_uppercase());
258        }
259        if let Some(sim) = self.sims.get(device_ref) {
260            return Ok(sim.udid.to_ascii_uppercase());
261        }
262        if let Some(sim) = self.sims.values().find(|s| s.device_name == device_ref) {
263            return Ok(sim.udid.to_ascii_uppercase());
264        }
265        let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
266        for (alias, sim) in &self.sims {
267            known.push(alias.clone());
268            known.push(sim.device_name.clone());
269        }
270        Err(RegistryError::UnknownDevice {
271            device_ref: device_ref.to_string(),
272            known,
273        })
274    }
275
276    /// All registered sims, keyed by alias.
277    pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
278        &self.sims
279    }
280
281    /// Look up a [`RegisteredSim`] by alias key, device name, or UDID.
282    /// Returns `None` if no entry matches any of the three. Mirrors
283    /// [`Self::resolve`]'s match precedence so cli callers can fetch
284    /// the full spec (e.g. `locale` field) after they already resolved
285    /// the UDID.
286    pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
287        if let Some(sim) = self.sims.get(device_ref) {
288            return Some(sim);
289        }
290        self.sims
291            .values()
292            .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
293    }
294}