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
+}