Skip to main content

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    /// v6.10 c2 — desired BCP 47 locale tag (e.g. `"en-US"`, `"ja-JP"`).
61    /// When set, `smix sim boot` enforces it via
62    /// `defaults write -g AppleLanguages + AppleLocale` and reboots the
63    /// sim if the current locale differs. Closes insight gol-611 §3
64    /// (sim-managed sims default to zh-Hans → SpringBoard / app text
65    /// mismatches English yaml matchers). `None` (field absent) =
66    /// honor whatever locale the sim boots with, no enforcement.
67    #[serde(default)]
68    pub locale: Option<String>,
69    /// v0.2.5 §Phase C — desired runner port (SmixRunner FlyingFox
70    /// HTTP port). When set, `smix runner up <alias>` binds the runner
71    /// to this port instead of the CLI default 22087. Two sims can
72    /// then run their own runner in parallel without port collision
73    /// (e.g. `sim-a.runnerPort = 22087` + `sim-b.runnerPort = 22088`).
74    /// Falls through to `--runner-port` flag or `SMIX_RUNNER_PORT` env
75    /// when absent. See insight-roadmap.md §I.
76    #[serde(default, rename = "runnerPort")]
77    pub runner_port: Option<u16>,
78}
79
80#[derive(Debug, Deserialize)]
81struct RegistryFile {
82    #[allow(dead_code)]
83    version: u32,
84    sims: BTreeMap<String, RegisteredSim>,
85}
86
87/// Loaded view of `.smix/sims.json`, keyed by alias.
88#[derive(Debug)]
89pub struct SimRegistry {
90    sims: BTreeMap<String, RegisteredSim>,
91}
92
93/// Whether `s` has CoreSimulator UDID form (8-4-4-4-12 hex).
94///
95/// UDID-form input is always treated as a deliberate instruction and
96/// never requires registry membership.
97pub fn is_udid(s: &str) -> bool {
98    let bytes = s.as_bytes();
99    if bytes.len() != 36 {
100        return false;
101    }
102    for (i, b) in bytes.iter().enumerate() {
103        match i {
104            8 | 13 | 18 | 23 => {
105                if *b != b'-' {
106                    return false;
107                }
108            }
109            _ => {
110                if !b.is_ascii_hexdigit() {
111                    return false;
112                }
113            }
114        }
115    }
116    true
117}
118
119impl SimRegistry {
120    /// Parse the registry file at `path`.
121    pub fn load(path: &Path) -> Result<Self, RegistryError> {
122        let text = std::fs::read_to_string(path).map_err(|source| RegistryError::Io {
123            path: path.display().to_string(),
124            source,
125        })?;
126        let file: RegistryFile =
127            serde_json::from_str(&text).map_err(|e| RegistryError::Malformed {
128                path: path.display().to_string(),
129                detail: e.to_string(),
130            })?;
131        Ok(Self { sims: file.sims })
132    }
133
134    /// Walk up from `start` looking for `.smix/sims.json`.
135    pub fn discover(start: &Path) -> Option<PathBuf> {
136        let mut dir = Some(start);
137        while let Some(d) = dir {
138            let candidate = d.join(".smix/sims.json");
139            if candidate.is_file() {
140                return Some(candidate);
141            }
142            dir = d.parent();
143        }
144        None
145    }
146
147    /// Resolve a device ref to a UDID.
148    ///
149    /// An explicit UDID passes through (normalized to uppercase) whether
150    /// or not it is registered — UDID-form input is always a deliberate
151    /// instruction. Otherwise the ref must match an alias key or a
152    /// `deviceName` exactly; anything else errors listing what exists.
153    pub fn resolve(&self, device_ref: &str) -> Result<String, RegistryError> {
154        if is_udid(device_ref) {
155            return Ok(device_ref.to_ascii_uppercase());
156        }
157        if let Some(sim) = self.sims.get(device_ref) {
158            return Ok(sim.udid.to_ascii_uppercase());
159        }
160        if let Some(sim) = self.sims.values().find(|s| s.device_name == device_ref) {
161            return Ok(sim.udid.to_ascii_uppercase());
162        }
163        let mut known: Vec<String> = Vec::with_capacity(self.sims.len() * 2);
164        for (alias, sim) in &self.sims {
165            known.push(alias.clone());
166            known.push(sim.device_name.clone());
167        }
168        Err(RegistryError::UnknownDevice {
169            device_ref: device_ref.to_string(),
170            known,
171        })
172    }
173
174    /// All registered sims, keyed by alias.
175    pub fn sims(&self) -> &BTreeMap<String, RegisteredSim> {
176        &self.sims
177    }
178
179    /// v6.10 c2 — look up a [`RegisteredSim`] by alias key, device name,
180    /// or UDID. Returns `None` if no entry matches any of the three.
181    /// Mirrors [`Self::resolve`]'s match precedence so cli callers can
182    /// fetch the full spec (e.g. `locale` field) after they already
183    /// resolved the UDID.
184    pub fn lookup(&self, device_ref: &str) -> Option<&RegisteredSim> {
185        if let Some(sim) = self.sims.get(device_ref) {
186            return Some(sim);
187        }
188        self.sims
189            .values()
190            .find(|sim| sim.device_name == device_ref || sim.udid.eq_ignore_ascii_case(device_ref))
191    }
192}