@smbcloud / cli / commits / afd8546

Add xcrs crate

Seto Elkahfi committed Jul 30, 2026 at 11:44 UTC afd8546ecec8462b884dda72378ad26b0f021b1e
9 files changed +741 -3
Cargo.lock
+14
@@ -2627,6 +2627,7 @@ dependencies = [
2627 "tracing-bunyan-formatter",
2628 "tracing-subscriber",
2629 "url-builder",
2630 + "xcrs",
2631 ]
2632
2633 [[package]]
@@ -3993,6 +3994,19 @@ version = "0.6.3"
3994 source = "registry+https://github.com/rust-lang/crates.io-index"
3995 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
3996
3997 +[[package]]
3998 +name = "xcrs"
3999 +version = "0.1.0"
4000 +dependencies = [
4001 + "anyhow",
4002 + "clap 4.6.2",
4003 + "rmcp",
4004 + "schemars",
4005 + "serde",
4006 + "serde_json",
4007 + "tokio",
4008 +]
4009 +
4010 [[package]]
4011 name = "yoke"
4012 version = "0.8.3"
Cargo.toml
+2 -1
@@ -1,6 +1,6 @@
1 [workspace]
2 resolver = "2"
3 -members = ["crates/cli", "crates/smbcloud-*"]
3 +members = ["crates/cli", "crates/smbcloud-*", "crates/xcrs"]
4
5 [workspace.dependencies]
6 anyhow = "1.0.58"
@@ -55,3 +55,4 @@ tsync = "2"
55 uniffi = "0.31"
56 url-builder = "0.1.1"
57 wasm-bindgen = "0.2"
58 +xcrs = { version = "0.1.0", path = "crates/xcrs" }
crates/cli/Cargo.toml
+1
@@ -63,6 +63,7 @@ tracing = { workspace = true, features = ["log"] }
63 tracing-bunyan-formatter = { workspace = true }
64 tracing-subscriber = { workspace = true, features = ["registry", "env-filter"] }
65 url-builder = { workspace = true }
66 +xcrs = { workspace = true }
67
68 [target.'cfg(not(windows))'.dependencies]
69 openssl = { workspace = true }
crates/cli/src/mcp.rs
+7 -2
@@ -63,6 +63,9 @@ pub struct SmbMcpServer {
63 environment: Environment,
64 }
65
66 +#[path = "mcp_xcrs.rs"]
67 +mod mcp_xcrs;
68 +
69 impl SmbMcpServer {
70 pub fn new(environment: Environment) -> Self {
71 Self { environment }
@@ -341,7 +344,7 @@ struct AuthAppDeleteArgs {
344 id: String,
345 }
346
344 -#[tool_router]
347 +#[tool_router(router = cloud_tool_router, vis = "pub(crate)")]
348 impl SmbMcpServer {
349 #[tool(description = "Get the authenticated smbCloud user's account info. \
350 Requires a prior `smb login`; returns the user as JSON.")]
@@ -912,7 +915,9 @@ impl SmbMcpServer {
915 }
916 }
917
915 -#[tool_handler]
918 +#[tool_handler(
919 + router = (Self::cloud_tool_router() + Self::xcrs_tool_router())
920 +)]
921 impl ServerHandler for SmbMcpServer {
922 fn get_info(&self) -> ServerInfo {
923 // `Implementation` is `#[non_exhaustive]`, so start from the build-env
crates/cli/src/mcp_xcrs.rs new
+8
@@ -0,0 +1,8 @@
1 +use super::SmbMcpServer;
2 +
3 +xcrs::xcrs_mcp_tools!(
4 + SmbMcpServer,
5 + "smb_simulator_list",
6 + "smb_simulator_find",
7 + "smb_ios_app_test"
8 +);
crates/xcrs/Cargo.toml new
+27
@@ -0,0 +1,27 @@
1 +[package]
2 +name = "xcrs"
3 +version = "0.1.0"
4 +edition = "2021"
5 +authors = ["Seto Elkahfi <seto@smbcloud.xyz>"]
6 +description = "Rust abstraction for Xcode command line tools."
7 +license = "Apache-2.0"
8 +repository = "https://github.com/smbcloudXYZ/smbcloud-cli"
9 +documentation = "https://smbcloud.xyz/posts"
10 +keywords = ["xcode", "ios", "simulator", "cli"]
11 +categories = ["command-line-utilities", "development-tools"]
12 +
13 +[lib]
14 +path = "src/xcrs.rs"
15 +
16 +[[bin]]
17 +name = "xcrs"
18 +path = "src/main.rs"
19 +
20 +[dependencies]
21 +anyhow = { workspace = true }
22 +clap = { workspace = true, features = ["derive", "env"] }
23 +rmcp = { workspace = true }
24 +schemars = { workspace = true }
25 +serde = { workspace = true, features = ["derive"] }
26 +serde_json = { workspace = true }
27 +tokio = { workspace = true, features = ["full"] }
crates/xcrs/src/main.rs new
+111
@@ -0,0 +1,111 @@
1 +use anyhow::{anyhow, Result};
2 +use clap::{Parser, Subcommand};
3 +use std::path::PathBuf;
4 +use xcrs::{IosAppTest, XcodeCommandLineTools};
5 +
6 +#[derive(Debug, Parser)]
7 +#[command(name = "xcrs")]
8 +#[command(about = "Rust abstraction over Xcode command line tools")]
9 +struct Cli {
10 + /// Run as a standalone Model Context Protocol server over stdio.
11 + #[arg(long)]
12 + mcp: bool,
13 + #[command(subcommand)]
14 + command: Option<Commands>,
15 +}
16 +
17 +#[derive(Debug, Subcommand)]
18 +enum Commands {
19 + #[command(subcommand)]
20 + Simulator(SimulatorCommand),
21 + IosAppTest {
22 + #[arg(long, conflicts_with = "simulator_udid")]
23 + simulator_name: Option<String>,
24 + #[arg(long, conflicts_with = "simulator_name")]
25 + simulator_udid: Option<String>,
26 + #[arg(long)]
27 + app_path: PathBuf,
28 + #[arg(long)]
29 + bundle_id: String,
30 + #[arg(long)]
31 + terminate_before_launch: bool,
32 + #[arg(long)]
33 + json: bool,
34 + },
35 +}
36 +
37 +#[derive(Debug, Subcommand)]
38 +enum SimulatorCommand {
39 + Find {
40 + #[arg(long)]
41 + name: String,
42 + #[arg(long)]
43 + json: bool,
44 + },
45 + List {
46 + #[arg(long)]
47 + json: bool,
48 + },
49 +}
50 +
51 +#[tokio::main]
52 +async fn main() -> Result<()> {
53 + let cli = Cli::parse();
54 +
55 + if cli.mcp {
56 + return xcrs::mcp::serve().await;
57 + }
58 +
59 + let command = cli
60 + .command
61 + .ok_or_else(|| anyhow!("a command is required unless --mcp is provided"))?;
62 + let tools = XcodeCommandLineTools::new();
63 +
64 + match command {
65 + Commands::Simulator(SimulatorCommand::Find { name, json }) => {
66 + let simulator = tools.simctl().find_simulator_by_name(&name)?;
67 + if json {
68 + println!("{}", serde_json::to_string_pretty(&simulator)?);
69 + } else {
70 + println!("{} {}", simulator.name, simulator.udid);
71 + }
72 + }
73 + Commands::Simulator(SimulatorCommand::List { json }) => {
74 + let simulators = tools.simctl().list_simulators()?;
75 + if json {
76 + println!("{}", serde_json::to_string_pretty(&simulators)?);
77 + } else {
78 + for simulator in simulators {
79 + println!("{} {} {}", simulator.name, simulator.udid, simulator.state);
80 + }
81 + }
82 + }
83 + Commands::IosAppTest {
84 + simulator_name,
85 + simulator_udid,
86 + app_path,
87 + bundle_id,
88 + terminate_before_launch,
89 + json,
90 + } => {
91 + let result = tools.run_ios_app_test(&IosAppTest {
92 + simulator_name,
93 + simulator_udid,
94 + app_path,
95 + bundle_id,
96 + terminate_before_launch,
97 + })?;
98 +
99 + if json {
100 + println!("{}", serde_json::to_string_pretty(&result)?);
101 + } else {
102 + println!(
103 + "Launched {} on {} ({})",
104 + result.bundle_id, result.simulator.name, result.simulator.udid
105 + );
106 + }
107 + }
108 + }
109 +
110 + Ok(())
111 +}
crates/xcrs/src/mcp.rs new
+170
@@ -0,0 +1,170 @@
1 +use {
2 + anyhow::{anyhow, Result},
3 + rmcp::{
4 + model::{Implementation, ServerCapabilities, ServerInfo},
5 + transport::stdio,
6 + ServerHandler, ServiceExt,
7 + },
8 + schemars::JsonSchema,
9 + serde::Deserialize,
10 + std::path::PathBuf,
11 +};
12 +
13 +#[derive(Debug, Deserialize, JsonSchema)]
14 +pub struct SimulatorFindArgs {
15 + /// Exact simulator name to find.
16 + pub name: String,
17 +}
18 +
19 +#[derive(Debug, Deserialize, JsonSchema)]
20 +pub struct IosAppTestArgs {
21 + /// Exact simulator name to use.
22 + #[serde(default)]
23 + pub simulator_name: Option<String>,
24 + /// Simulator UDID to use.
25 + #[serde(default)]
26 + pub simulator_udid: Option<String>,
27 + /// Path to the `.app` bundle.
28 + pub app_path: PathBuf,
29 + /// Installed app bundle identifier.
30 + pub bundle_id: String,
31 + /// Terminate the app before launching it.
32 + #[serde(default)]
33 + pub terminate_before_launch: bool,
34 +}
35 +
36 +#[derive(Debug, Default)]
37 +pub struct XcrsMcpServer;
38 +
39 +impl XcrsMcpServer {
40 + pub fn new() -> Self {
41 + Self
42 + }
43 +}
44 +
45 +#[macro_export]
46 +macro_rules! xcrs_mcp_tools {
47 + ($server:ty, $simulator_list_name:literal, $simulator_find_name:literal, $ios_app_test_name:literal) => {
48 + #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
49 + impl $server {
50 + #[::rmcp::tool(
51 + name = $simulator_list_name,
52 + description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state."
53 + )]
54 + async fn simulator_list(
55 + &self,
56 + ) -> ::std::result::Result<
57 + ::rmcp::model::CallToolResult,
58 + ::rmcp::model::ErrorData,
59 + > {
60 + let simulators = $crate::XcodeCommandLineTools::new()
61 + .simctl()
62 + .list_simulators()
63 + .map_err(|error| {
64 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
65 + })?;
66 + Ok(::rmcp::model::CallToolResult::success(vec![
67 + ::rmcp::model::ContentBlock::json(&simulators)?,
68 + ]))
69 + }
70 +
71 + #[::rmcp::tool(
72 + name = $simulator_find_name,
73 + description = "Find an iOS simulator by its exact name and return its details as JSON."
74 + )]
75 + async fn simulator_find(
76 + &self,
77 + ::rmcp::handler::server::wrapper::Parameters(
78 + args,
79 + ): ::rmcp::handler::server::wrapper::Parameters<
80 + $crate::mcp::SimulatorFindArgs,
81 + >,
82 + ) -> ::std::result::Result<
83 + ::rmcp::model::CallToolResult,
84 + ::rmcp::model::ErrorData,
85 + > {
86 + let simulator = $crate::XcodeCommandLineTools::new()
87 + .simctl()
88 + .find_simulator_by_name(&args.name)
89 + .map_err(|error| {
90 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
91 + })?;
92 + Ok(::rmcp::model::CallToolResult::success(vec![
93 + ::rmcp::model::ContentBlock::json(&simulator)?,
94 + ]))
95 + }
96 +
97 + #[::rmcp::tool(
98 + name = $ios_app_test_name,
99 + description = "Boot an iOS simulator, install an .app bundle, optionally terminate the bundle, launch it, and return its app container path."
100 + )]
101 + async fn ios_app_test(
102 + &self,
103 + ::rmcp::handler::server::wrapper::Parameters(
104 + args,
105 + ): ::rmcp::handler::server::wrapper::Parameters<
106 + $crate::mcp::IosAppTestArgs,
107 + >,
108 + ) -> ::std::result::Result<
109 + ::rmcp::model::CallToolResult,
110 + ::rmcp::model::ErrorData,
111 + > {
112 + if args.simulator_name.is_none() && args.simulator_udid.is_none() {
113 + return Err(::rmcp::model::ErrorData::invalid_request(
114 + "Provide either simulator_name or simulator_udid.",
115 + None,
116 + ));
117 + }
118 +
119 + let result = $crate::XcodeCommandLineTools::new()
120 + .run_ios_app_test(&$crate::IosAppTest {
121 + simulator_name: args.simulator_name,
122 + simulator_udid: args.simulator_udid,
123 + app_path: args.app_path,
124 + bundle_id: args.bundle_id,
125 + terminate_before_launch: args.terminate_before_launch,
126 + })
127 + .map_err(|error| {
128 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
129 + })?;
130 + Ok(::rmcp::model::CallToolResult::success(vec![
131 + ::rmcp::model::ContentBlock::json(&result)?,
132 + ]))
133 + }
134 + }
135 + };
136 +}
137 +
138 +xcrs_mcp_tools!(
139 + XcrsMcpServer,
140 + "xcrs_simulator_list",
141 + "xcrs_simulator_find",
142 + "xcrs_ios_app_test"
143 +);
144 +
145 +#[rmcp::tool_handler(router = Self::xcrs_tool_router())]
146 +impl ServerHandler for XcrsMcpServer {
147 + fn get_info(&self) -> ServerInfo {
148 + let mut implementation = Implementation::from_build_env();
149 + implementation.name = "xcrs".to_string();
150 + implementation.version = env!("CARGO_PKG_VERSION").to_string();
151 +
152 + ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
153 + .with_server_info(implementation)
154 + .with_instructions(
155 + "xcrs exposes Xcode command line tools for iOS simulators and app testing.",
156 + )
157 + }
158 +}
159 +
160 +pub async fn serve() -> Result<()> {
161 + let running = XcrsMcpServer::new()
162 + .serve(stdio())
163 + .await
164 + .map_err(|error| anyhow!("Failed to start xcrs MCP server: {error}"))?;
165 + running
166 + .waiting()
167 + .await
168 + .map_err(|error| anyhow!("xcrs MCP server stopped unexpectedly: {error}"))?;
169 + Ok(())
170 +}
crates/xcrs/src/xcrs.rs new
+401
@@ -0,0 +1,401 @@
1 +use anyhow::{anyhow, Context, Result};
2 +use serde::{Deserialize, Serialize};
3 +use std::collections::BTreeMap;
4 +use std::ffi::OsStr;
5 +use std::path::{Path, PathBuf};
6 +use std::process::{Command, Output};
7 +
8 +pub mod mcp;
9 +
10 +const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id=";
11 +
12 +#[derive(Debug, Clone)]
13 +pub struct XcodeCommandLineTools {
14 + xcrun_path: PathBuf,
15 + xcodebuild_path: PathBuf,
16 +}
17 +
18 +impl Default for XcodeCommandLineTools {
19 + fn default() -> Self {
20 + Self {
21 + xcrun_path: PathBuf::from("xcrun"),
22 + xcodebuild_path: PathBuf::from("xcodebuild"),
23 + }
24 + }
25 +}
26 +
27 +impl XcodeCommandLineTools {
28 + pub fn new() -> Self {
29 + Self::default()
30 + }
31 +
32 + pub fn with_paths(xcrun_path: impl Into<PathBuf>, xcodebuild_path: impl Into<PathBuf>) -> Self {
33 + Self {
34 + xcrun_path: xcrun_path.into(),
35 + xcodebuild_path: xcodebuild_path.into(),
36 + }
37 + }
38 +
39 + pub fn simctl(&self) -> Simctl<'_> {
40 + Simctl { tools: self }
41 + }
42 +
43 + pub fn xcodebuild(&self) -> Xcodebuild<'_> {
44 + Xcodebuild { tools: self }
45 + }
46 +
47 + fn run_xcrun<I, S>(&self, args: I) -> Result<String>
48 + where
49 + I: IntoIterator<Item = S>,
50 + S: AsRef<OsStr>,
51 + {
52 + run_command(&self.xcrun_path, args)
53 + }
54 +
55 + fn run_xcodebuild<I, S>(&self, args: I) -> Result<String>
56 + where
57 + I: IntoIterator<Item = S>,
58 + S: AsRef<OsStr>,
59 + {
60 + run_command(&self.xcodebuild_path, args)
61 + }
62 +}
63 +
64 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65 +pub struct Simulator {
66 + pub runtime_identifier: String,
67 + pub name: String,
68 + pub udid: String,
69 + pub state: String,
70 + pub is_available: bool,
71 +}
72 +
73 +impl Simulator {
74 + pub fn is_booted(&self) -> bool {
75 + self.state == "Booted"
76 + }
77 +}
78 +
79 +#[derive(Debug, Deserialize)]
80 +struct SimctlDeviceList {
81 + devices: BTreeMap<String, Vec<SimctlDevice>>,
82 +}
83 +
84 +#[derive(Debug, Deserialize)]
85 +struct SimctlDevice {
86 + name: String,
87 + udid: String,
88 + state: String,
89 + #[serde(rename = "isAvailable")]
90 + is_available: Option<bool>,
91 +}
92 +
93 +pub struct Simctl<'a> {
94 + tools: &'a XcodeCommandLineTools,
95 +}
96 +
97 +impl Simctl<'_> {
98 + pub fn list_simulators(&self) -> Result<Vec<Simulator>> {
99 + let output = self
100 + .tools
101 + .run_xcrun(["simctl", "list", "devices", "--json"])?;
102 + parse_simctl_devices(&output)
103 + }
104 +
105 + pub fn find_simulator_by_name(&self, name: &str) -> Result<Simulator> {
106 + self.list_simulators()?
107 + .into_iter()
108 + .find(|simulator| simulator.name == name)
109 + .ok_or_else(|| anyhow!("iOS simulator named '{name}' was not found"))
110 + }
111 +
112 + pub fn find_simulator_by_udid(&self, udid: &str) -> Result<Simulator> {
113 + self.list_simulators()?
114 + .into_iter()
115 + .find(|simulator| simulator.udid == udid)
116 + .ok_or_else(|| anyhow!("iOS simulator with UDID '{udid}' was not found"))
117 + }
118 +
119 + pub fn boot(&self, udid: &str) -> Result<()> {
120 + if self.find_simulator_by_udid(udid)?.is_booted() {
121 + return Ok(());
122 + }
123 +
124 + self.tools.run_xcrun(["simctl", "boot", udid])?;
125 + Ok(())
126 + }
127 +
128 + pub fn shutdown(&self, udid: &str) -> Result<()> {
129 + if !self.find_simulator_by_udid(udid)?.is_booted() {
130 + return Ok(());
131 + }
132 +
133 + self.tools.run_xcrun(["simctl", "shutdown", udid])?;
134 + Ok(())
135 + }
136 +
137 + pub fn install_app(&self, udid: &str, app_path: impl AsRef<Path>) -> Result<()> {
138 + let app_path = app_path.as_ref();
139 + ensure_path_exists(app_path)?;
140 +
141 + let path = app_path
142 + .to_str()
143 + .ok_or_else(|| anyhow!("app path is not valid UTF-8: {}", app_path.display()))?;
144 +
145 + self.tools.run_xcrun(["simctl", "install", udid, path])?;
146 + Ok(())
147 + }
148 +
149 + pub fn uninstall_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
150 + self.tools
151 + .run_xcrun(["simctl", "uninstall", udid, bundle_id])?;
152 + Ok(())
153 + }
154 +
155 + pub fn launch_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
156 + self.tools
157 + .run_xcrun(["simctl", "launch", udid, bundle_id])?;
158 + Ok(())
159 + }
160 +
161 + pub fn terminate_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
162 + self.tools
163 + .run_xcrun(["simctl", "terminate", udid, bundle_id])?;
164 + Ok(())
165 + }
166 +
167 + pub fn open_url(&self, udid: &str, url: &str) -> Result<()> {
168 + self.tools.run_xcrun(["simctl", "openurl", udid, url])?;
169 + Ok(())
170 + }
171 +
172 + pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result<PathBuf> {
173 + let output =
174 + self.tools
175 + .run_xcrun(["simctl", "get_app_container", udid, bundle_id, "app"])?;
176 + let path = output.trim();
177 + if path.is_empty() {
178 + return Err(anyhow!(
179 + "simctl returned an empty app container path for '{bundle_id}'"
180 + ));
181 + }
182 +
183 + Ok(PathBuf::from(path))
184 + }
185 +}
186 +
187 +#[derive(Debug, Clone)]
188 +pub enum XcodeProject {
189 + Project(PathBuf),
190 + Workspace(PathBuf),
191 +}
192 +
193 +#[derive(Debug, Clone)]
194 +pub struct BuildIosApp {
195 + pub project: XcodeProject,
196 + pub scheme: String,
197 + pub simulator_udid: String,
198 + pub derived_data_path: PathBuf,
199 + pub configuration: Option<String>,
200 +}
201 +
202 +pub struct Xcodebuild<'a> {
203 + tools: &'a XcodeCommandLineTools,
204 +}
205 +
206 +impl Xcodebuild<'_> {
207 + pub fn build_ios_app(&self, request: &BuildIosApp) -> Result<()> {
208 + let mut args = Vec::new();
209 + match &request.project {
210 + XcodeProject::Project(project_path) => {
211 + ensure_path_exists(project_path)?;
212 + args.push("-project".to_string());
213 + args.push(path_to_string(project_path)?);
214 + }
215 + XcodeProject::Workspace(workspace_path) => {
216 + ensure_path_exists(workspace_path)?;
217 + args.push("-workspace".to_string());
218 + args.push(path_to_string(workspace_path)?);
219 + }
220 + }
221 +
222 + args.push("-scheme".to_string());
223 + args.push(request.scheme.clone());
224 + args.push("-destination".to_string());
225 + args.push(format!(
226 + "{IOS_SIMULATOR_DESTINATION_PREFIX}{}",
227 + request.simulator_udid
228 + ));
229 + args.push("-derivedDataPath".to_string());
230 + args.push(path_to_string(&request.derived_data_path)?);
231 +
232 + if let Some(configuration) = &request.configuration {
233 + args.push("-configuration".to_string());
234 + args.push(configuration.clone());
235 + }
236 +
237 + args.push("build".to_string());
238 +
239 + self.tools.run_xcodebuild(args)?;
240 + Ok(())
241 + }
242 +}
243 +
244 +#[derive(Debug, Clone, Serialize)]
245 +pub struct IosAppTestResult {
246 + pub simulator: Simulator,
247 + pub bundle_id: String,
248 + pub app_container_path: PathBuf,
249 +}
250 +
251 +#[derive(Debug, Clone)]
252 +pub struct IosAppTest {
253 + pub simulator_name: Option<String>,
254 + pub simulator_udid: Option<String>,
255 + pub app_path: PathBuf,
256 + pub bundle_id: String,
257 + pub terminate_before_launch: bool,
258 +}
259 +
260 +impl XcodeCommandLineTools {
261 + pub fn run_ios_app_test(&self, request: &IosAppTest) -> Result<IosAppTestResult> {
262 + if request.simulator_name.is_none() && request.simulator_udid.is_none() {
263 + return Err(anyhow!(
264 + "either simulator_name or simulator_udid must be provided"
265 + ));
266 + }
267 +
268 + let simctl = self.simctl();
269 + let simulator = match (&request.simulator_udid, &request.simulator_name) {
270 + (Some(udid), _) => simctl.find_simulator_by_udid(udid)?,
271 + (None, Some(name)) => simctl.find_simulator_by_name(name)?,
272 + (None, None) => unreachable!("validated above"),
273 + };
274 +
275 + simctl.boot(&simulator.udid)?;
276 + simctl.install_app(&simulator.udid, &request.app_path)?;
277 +
278 + if request.terminate_before_launch {
279 + simctl.terminate_app(&simulator.udid, &request.bundle_id)?;
280 + }
281 +
282 + simctl.launch_app(&simulator.udid, &request.bundle_id)?;
283 + let app_container_path = simctl.app_container_path(&simulator.udid, &request.bundle_id)?;
284 +
285 + Ok(IosAppTestResult {
286 + simulator: simctl.find_simulator_by_udid(&simulator.udid)?,
287 + bundle_id: request.bundle_id.clone(),
288 + app_container_path,
289 + })
290 + }
291 +}
292 +
293 +pub fn parse_simctl_devices(output: &str) -> Result<Vec<Simulator>> {
294 + let list: SimctlDeviceList =
295 + serde_json::from_str(output).context("failed to parse simctl device list JSON")?;
296 +
297 + let mut simulators = Vec::new();
298 + for (runtime_identifier, devices) in list.devices {
299 + for device in devices {
300 + simulators.push(Simulator {
301 + runtime_identifier: runtime_identifier.clone(),
302 + name: device.name,
303 + udid: device.udid,
304 + state: device.state,
305 + is_available: device.is_available.unwrap_or(true),
306 + });
307 + }
308 + }
309 +
310 + Ok(simulators)
311 +}
312 +
313 +fn ensure_path_exists(path: &Path) -> Result<()> {
314 + if !path.exists() {
315 + return Err(anyhow!("path does not exist: {}", path.display()));
316 + }
317 +
318 + Ok(())
319 +}
320 +
321 +fn path_to_string(path: &Path) -> Result<String> {
322 + path.to_str()
323 + .map(ToOwned::to_owned)
324 + .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))
325 +}
326 +
327 +fn run_command<I, S>(program: &Path, args: I) -> Result<String>
328 +where
329 + I: IntoIterator<Item = S>,
330 + S: AsRef<OsStr>,
331 +{
332 + let output = Command::new(program)
333 + .args(args)
334 + .output()
335 + .with_context(|| format!("failed to run {}", program.display()))?;
336 +
337 + command_output_to_result(program, output)
338 +}
339 +
340 +fn command_output_to_result(program: &Path, output: Output) -> Result<String> {
341 + if output.status.success() {
342 + return String::from_utf8(output.stdout)
343 + .with_context(|| format!("{} wrote invalid UTF-8 to stdout", program.display()));
344 + }
345 +
346 + let stderr = String::from_utf8_lossy(&output.stderr);
347 + let stdout = String::from_utf8_lossy(&output.stdout);
348 + let message = [stderr.trim(), stdout.trim()]
349 + .into_iter()
350 + .filter(|part| !part.is_empty())
351 + .collect::<Vec<_>>()
352 + .join("\n");
353 +
354 + if message.is_empty() {
355 + return Err(anyhow!(
356 + "{} exited with status {}",
357 + program.display(),
358 + output.status
359 + ));
360 + }
361 +
362 + Err(anyhow!(
363 + "{} exited with status {}: {}",
364 + program.display(),
365 + output.status,
366 + message
367 + ))
368 +}
369 +
370 +#[cfg(test)]
371 +mod tests {
372 + use super::*;
373 +
374 + #[test]
375 + fn parses_simctl_devices() {
376 + let output = r#"{
377 + "devices": {
378 + "com.apple.CoreSimulator.SimRuntime.iOS-26-0": [
379 + {
380 + "lastBootedAt": "2026-07-30T08:00:00Z",
381 + "dataPath": "/tmp/device",
382 + "dataPathSize": 1,
383 + "logPath": "/tmp/logs",
384 + "udid": "00000000-0000-0000-0000-000000000000",
385 + "isAvailable": true,
386 + "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
387 + "state": "Booted",
388 + "name": "Test-iOS-26"
389 + }
390 + ]
391 + }
392 + }"#;
393 +
394 + let devices = parse_simctl_devices(output).expect("devices should parse");
395 +
396 + assert_eq!(devices.len(), 1);
397 + assert_eq!(devices[0].name, "Test-iOS-26");
398 + assert_eq!(devices[0].state, "Booted");
399 + assert!(devices[0].is_booted());
400 + }
401 +}