Skip to main content

rustferry_apple/
discovery.rs

1use std::env;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use serde::{Deserialize, Serialize};
5
6use crate::{AppleError, CommandSpec, IOS_DEVICE_TARGET, IOS_SIMULATOR_TARGET, run_command};
7
8/// Explicit, read-only inputs for Apple toolchain discovery.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct AppleDiscoveryOptions {
11    /// Preferred Xcode developer directory.
12    pub developer_dir: Option<Utf8PathBuf>,
13    /// Directories searched for host executables.
14    pub executable_search_paths: Vec<Utf8PathBuf>,
15    /// Existing directory used while probing tools.
16    pub current_dir: Utf8PathBuf,
17    /// Host operating-system override for deterministic tests.
18    pub host_os: String,
19    /// Host architecture override for deterministic tests.
20    pub host_arch: String,
21}
22
23impl AppleDiscoveryOptions {
24    /// Capture `DEVELOPER_DIR`, `PATH`, the current directory, and host identity.
25    pub fn from_environment() -> Self {
26        let developer_dir = env::var_os("DEVELOPER_DIR")
27            .and_then(|value| Utf8PathBuf::from_path_buf(value.into()).ok());
28        let executable_search_paths = env::var_os("PATH")
29            .map(|value| {
30                env::split_paths(&value)
31                    .filter_map(|path| Utf8PathBuf::from_path_buf(path).ok())
32                    .collect()
33            })
34            .unwrap_or_default();
35        let current_dir = env::current_dir()
36            .ok()
37            .and_then(|path| Utf8PathBuf::from_path_buf(path).ok())
38            .unwrap_or_else(|| Utf8PathBuf::from("."));
39        Self {
40            developer_dir,
41            executable_search_paths,
42            current_dir,
43            host_os: env::consts::OS.to_owned(),
44            host_arch: env::consts::ARCH.to_owned(),
45        }
46    }
47}
48
49impl Default for AppleDiscoveryOptions {
50    fn default() -> Self {
51        Self::from_environment()
52    }
53}
54
55/// Host executables relevant to Apple builds and diagnostics.
56#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
57pub struct AppleHostTools {
58    /// Cargo executable.
59    pub cargo: Option<Utf8PathBuf>,
60    /// rustup executable.
61    pub rustup: Option<Utf8PathBuf>,
62    /// Xcode developer-directory selector.
63    pub xcode_select: Option<Utf8PathBuf>,
64    /// Xcode command-line build driver.
65    pub xcodebuild: Option<Utf8PathBuf>,
66    /// Xcode tool lookup and SDK driver.
67    pub xcrun: Option<Utf8PathBuf>,
68    /// Property-list validator and extractor.
69    pub plutil: Option<Utf8PathBuf>,
70}
71
72/// Installed iPhone Simulator SDK selected by Xcode.
73#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
74pub struct SimulatorSdk {
75    /// SDK root returned by `xcrun`.
76    pub path: Utf8PathBuf,
77    /// Marketing SDK version.
78    pub version: String,
79    /// SDK build version, when reported.
80    pub build_version: Option<String>,
81}
82
83/// Installed physical-iPhone SDK selected by Xcode.
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct IosDeviceSdk {
86    /// Canonical SDK root returned by `xcrun --sdk iphoneos`.
87    pub path: Utf8PathBuf,
88    /// Marketing SDK version.
89    pub version: String,
90    /// SDK build version.
91    pub build_version: String,
92}
93
94/// One `CoreSimulator` runtime reported by `simctl`.
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct SimulatorRuntime {
97    /// Stable `CoreSimulator` runtime identifier.
98    pub identifier: String,
99    /// Human-readable runtime name.
100    pub name: String,
101    /// Runtime version.
102    pub version: String,
103    /// Whether `CoreSimulator` reports the runtime usable.
104    pub available: bool,
105}
106
107/// Complete, read-only Apple development inventory.
108#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
109pub struct AppleDiscovery {
110    /// Host operating system.
111    pub host_os: String,
112    /// Host CPU architecture.
113    pub host_arch: String,
114    /// Selected Xcode developer directory.
115    pub developer_dir: Option<Utf8PathBuf>,
116    /// Xcode version output.
117    pub xcode_version: Option<String>,
118    /// Selected iPhone Simulator SDK.
119    pub simulator_sdk: Option<SimulatorSdk>,
120    /// Selected physical-iPhone SDK.
121    pub device_sdk: Option<IosDeviceSdk>,
122    /// Installed `CoreSimulator` runtimes; optional for build-only workflows.
123    pub simulator_runtimes: Vec<SimulatorRuntime>,
124    /// Rust targets reported as installed by rustup.
125    pub installed_rust_targets: Vec<String>,
126    /// Discovered host tools.
127    pub host_tools: AppleHostTools,
128    /// Every executable directory searched.
129    pub executable_search_paths: Vec<Utf8PathBuf>,
130}
131
132impl AppleDiscovery {
133    /// Select the complete build-only toolchain for an Apple Silicon simulator bundle.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`AppleError`] when the host is unsupported, full Xcode/SDK or a
138    /// required executable is absent, or the simulator Rust target is not installed.
139    pub fn select_toolchain(&self) -> Result<AppleToolchain, AppleError> {
140        if self.host_os != "macos" {
141            return Err(AppleError::UnsupportedHost {
142                host: self.host_os.clone(),
143            });
144        }
145        let developer_dir = self
146            .developer_dir
147            .clone()
148            .filter(|path| is_full_xcode(path))
149            .ok_or_else(|| AppleError::XcodeMissing {
150                developer_dir: self.developer_dir.clone(),
151                fix: "Install full Xcode, run `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` yourself if needed, then `cargo ferry doctor`. cargo-ferry never invokes sudo.".to_owned(),
152            })?;
153        let simulator_sdk = self
154            .simulator_sdk
155            .clone()
156            .ok_or_else(|| AppleError::SimulatorSdkMissing {
157                fix: "Open Xcode Settings > Platforms, install an iOS platform SDK, then run `cargo ferry doctor`. A Simulator device/runtime is not required for build-only use.".to_owned(),
158            })?;
159        let cargo = require_tool(
160            "cargo",
161            self.host_tools.cargo.as_ref(),
162            &self.executable_search_paths,
163            "Install Rust with rustup, then run `cargo ferry doctor`.",
164        )?;
165        let rustup = require_tool(
166            "rustup",
167            self.host_tools.rustup.as_ref(),
168            &self.executable_search_paths,
169            "Install rustup so the iOS Simulator Rust target can be verified.",
170        )?;
171        let xcodebuild = require_tool(
172            "xcodebuild",
173            self.host_tools.xcodebuild.as_ref(),
174            &self.executable_search_paths,
175            "Install full Xcode and select its developer directory.",
176        )?;
177        let xcrun = require_tool(
178            "xcrun",
179            self.host_tools.xcrun.as_ref(),
180            &self.executable_search_paths,
181            "Install full Xcode and select its developer directory.",
182        )?;
183        let plutil = require_tool(
184            "plutil",
185            self.host_tools.plutil.as_ref(),
186            &self.executable_search_paths,
187            "Restore the macOS command-line tools or reinstall Xcode.",
188        )?;
189        if !self
190            .installed_rust_targets
191            .iter()
192            .any(|target| target == IOS_SIMULATOR_TARGET)
193        {
194            return Err(AppleError::RustTargetMissing {
195                target: IOS_SIMULATOR_TARGET.to_owned(),
196            });
197        }
198        Ok(AppleToolchain {
199            developer_dir,
200            xcode_version: self.xcode_version.clone().unwrap_or_default(),
201            simulator_sdk,
202            simulator_runtime_available: has_available_ios_runtime(&self.simulator_runtimes),
203            cargo,
204            rustup,
205            xcodebuild,
206            xcrun,
207            plutil,
208            host_arch: self.host_arch.clone(),
209        })
210    }
211
212    /// Select a complete physical-iPhone toolchain without requiring Simulator support.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`AppleError`] when the host is unsupported, the selected developer
217    /// directory is not a full Xcode installation with iPhoneOS support, required
218    /// tools are absent or non-absolute, SDK evidence is incomplete, or the exact
219    /// physical-iPhone Rust target is not installed.
220    pub fn select_device_toolchain(&self) -> Result<IosDeviceToolchain, AppleError> {
221        if self.host_os != "macos" {
222            return Err(AppleError::UnsupportedHost {
223                host: self.host_os.clone(),
224            });
225        }
226        let developer_dir = self
227            .developer_dir
228            .as_ref()
229            .and_then(|path| path.canonicalize_utf8().ok())
230            .filter(|path| is_full_device_xcode(path))
231            .ok_or_else(|| AppleError::XcodeMissing {
232                developer_dir: self.developer_dir.clone(),
233                fix: "Install full Xcode with the iOS platform, then select that exact developer directory. cargo-ferry never invokes sudo or changes xcode-select.".to_owned(),
234            })?;
235        let device_sdk = self
236            .device_sdk
237            .as_ref()
238            .and_then(|sdk| canonical_device_sdk(sdk, &developer_dir))
239            .ok_or_else(|| {
240                AppleError::InvalidRequest(
241                    "an iPhoneOS SDK with path, version, and build-version evidence was not found through `xcrun --sdk iphoneos`; install the iOS platform in the selected Xcode installation"
242                        .to_owned(),
243                )
244            })?;
245        let xcode_version = self
246            .xcode_version
247            .clone()
248            .filter(|value| !value.trim().is_empty())
249            .ok_or_else(|| {
250                AppleError::InvalidRequest(
251                    "the selected Xcode installation did not report version evidence".to_owned(),
252                )
253            })?;
254        let cargo = require_absolute_tool(
255            "cargo",
256            self.host_tools.cargo.as_ref(),
257            &self.executable_search_paths,
258            "Install Rust with rustup and expose cargo through an absolute PATH entry.",
259        )?;
260        let rustup = require_absolute_tool(
261            "rustup",
262            self.host_tools.rustup.as_ref(),
263            &self.executable_search_paths,
264            "Install rustup and expose it through an absolute PATH entry so the physical-iPhone target can be verified.",
265        )?;
266        let xcodebuild = require_absolute_tool(
267            "xcodebuild",
268            self.host_tools.xcodebuild.as_ref(),
269            &self.executable_search_paths,
270            "Install full Xcode and expose xcodebuild through an absolute PATH entry.",
271        )?;
272        let xcrun = require_absolute_tool(
273            "xcrun",
274            self.host_tools.xcrun.as_ref(),
275            &self.executable_search_paths,
276            "Restore the macOS command-line tools and expose xcrun through an absolute PATH entry.",
277        )?;
278        let plutil = require_absolute_tool(
279            "plutil",
280            self.host_tools.plutil.as_ref(),
281            &self.executable_search_paths,
282            "Restore the macOS command-line tools and expose plutil through an absolute PATH entry.",
283        )?;
284        if !self
285            .installed_rust_targets
286            .iter()
287            .any(|target| target == IOS_DEVICE_TARGET)
288        {
289            return Err(AppleError::RustTargetMissing {
290                target: IOS_DEVICE_TARGET.to_owned(),
291            });
292        }
293        Ok(IosDeviceToolchain {
294            developer_dir,
295            xcode_version,
296            device_sdk,
297            cargo,
298            rustup,
299            xcodebuild,
300            xcrun,
301            plutil,
302            host_arch: self.host_arch.clone(),
303        })
304    }
305}
306
307/// Selected tools and SDK used by one iOS Simulator build.
308#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
309pub struct AppleToolchain {
310    /// Full Xcode developer directory.
311    pub developer_dir: Utf8PathBuf,
312    /// Xcode version output.
313    pub xcode_version: String,
314    /// Selected iPhone Simulator SDK.
315    pub simulator_sdk: SimulatorSdk,
316    /// Whether `CoreSimulator` reports at least one usable iOS runtime.
317    pub simulator_runtime_available: bool,
318    /// Cargo executable.
319    pub cargo: Utf8PathBuf,
320    /// rustup executable used to verify targets.
321    pub rustup: Utf8PathBuf,
322    /// Xcode build driver.
323    pub xcodebuild: Utf8PathBuf,
324    /// Xcode tool and SDK driver.
325    pub xcrun: Utf8PathBuf,
326    /// Property-list tool.
327    pub plutil: Utf8PathBuf,
328    /// Host architecture retained for diagnostics.
329    pub host_arch: String,
330}
331
332/// Selected tools and SDK for one physical-iPhone build.
333#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
334pub struct IosDeviceToolchain {
335    /// Canonical full-Xcode developer directory.
336    pub developer_dir: Utf8PathBuf,
337    /// Exact `xcodebuild -version` output.
338    pub xcode_version: String,
339    /// Selected physical-iPhone SDK evidence.
340    pub device_sdk: IosDeviceSdk,
341    /// Absolute Cargo executable.
342    pub cargo: Utf8PathBuf,
343    /// Absolute rustup executable used to verify targets.
344    pub rustup: Utf8PathBuf,
345    /// Absolute Xcode build driver.
346    pub xcodebuild: Utf8PathBuf,
347    /// Absolute Xcode SDK/tool driver.
348    pub xcrun: Utf8PathBuf,
349    /// Absolute property-list tool.
350    pub plutil: Utf8PathBuf,
351    /// Host architecture retained for diagnostics.
352    pub host_arch: String,
353}
354
355/// Inspect Xcode, the Simulator SDK/runtimes, and Rust targets without changing the host.
356///
357/// # Errors
358///
359/// Returns [`AppleError`] when the configured probe working directory does not exist.
360#[allow(clippy::too_many_lines)]
361pub fn discover_apple(options: &AppleDiscoveryOptions) -> Result<AppleDiscovery, AppleError> {
362    if !options.current_dir.is_dir() {
363        return Err(AppleError::InvalidRequest(format!(
364            "Apple discovery working directory does not exist: {}",
365            options.current_dir
366        )));
367    }
368    let mut search_paths = options.executable_search_paths.clone();
369    for conventional in ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"] {
370        let path = Utf8PathBuf::from(conventional);
371        if !search_paths.contains(&path) {
372            search_paths.push(path);
373        }
374    }
375    let mut tools = AppleHostTools {
376        cargo: find_executable("cargo", &search_paths),
377        rustup: find_executable("rustup", &search_paths),
378        xcode_select: find_executable("xcode-select", &search_paths),
379        xcodebuild: find_executable("xcodebuild", &search_paths),
380        xcrun: find_executable("xcrun", &search_paths),
381        plutil: find_executable("plutil", &search_paths),
382    };
383
384    let developer_dir = options.developer_dir.clone().or_else(|| {
385        tools.xcode_select.as_ref().and_then(|program| {
386            probe_text(
387                "select Xcode developer directory",
388                program,
389                &["-p"],
390                &options.current_dir,
391                None,
392            )
393            .map(Utf8PathBuf::from)
394        })
395    });
396    let developer_environment = developer_dir
397        .as_ref()
398        .map(|path| ("DEVELOPER_DIR", path.as_str()));
399
400    let xcode_version = tools.xcodebuild.as_ref().and_then(|program| {
401        probe_text(
402            "read Xcode version",
403            program,
404            &["-version"],
405            &options.current_dir,
406            developer_environment,
407        )
408    });
409    let simulator_sdk = tools.xcrun.as_ref().and_then(|xcrun| {
410        discover_simulator_sdk(xcrun, &options.current_dir, developer_environment)
411    });
412    let device_sdk = tools
413        .xcrun
414        .as_ref()
415        .and_then(|xcrun| discover_device_sdk(xcrun, &options.current_dir, developer_environment));
416    let simulator_runtimes = tools
417        .xcrun
418        .as_ref()
419        .and_then(|xcrun| {
420            probe_text(
421                "list iOS Simulator runtimes",
422                xcrun,
423                &["simctl", "list", "runtimes", "--json"],
424                &options.current_dir,
425                developer_environment,
426            )
427        })
428        .map_or_else(Vec::new, |source| parse_runtimes(&source));
429    if let Some(rustup) = tools.rustup.as_ref()
430        && let Some(cargo) = probe_text(
431            "locate active Cargo executable",
432            rustup,
433            &["which", "cargo"],
434            &options.current_dir,
435            None,
436        )
437        .map(Utf8PathBuf::from)
438        .filter(|path| path.is_absolute() && is_executable_file(path))
439    {
440        tools.cargo = Some(cargo);
441    }
442    let installed_rust_targets = tools
443        .rustup
444        .as_ref()
445        .and_then(|rustup| {
446            probe_text(
447                "list installed Rust targets",
448                rustup,
449                &["target", "list", "--installed"],
450                &options.current_dir,
451                None,
452            )
453        })
454        .map(|output| {
455            output
456                .lines()
457                .map(str::trim)
458                .filter(|line| !line.is_empty())
459                .map(ToOwned::to_owned)
460                .collect()
461        })
462        .unwrap_or_default();
463
464    if tools.plutil.is_none()
465        && let Some(xcrun) = &tools.xcrun
466    {
467        tools.plutil = probe_text(
468            "locate plutil",
469            xcrun,
470            &["--find", "plutil"],
471            &options.current_dir,
472            developer_environment,
473        )
474        .map(Utf8PathBuf::from)
475        .filter(|path| path.is_file());
476    }
477
478    Ok(AppleDiscovery {
479        host_os: options.host_os.clone(),
480        host_arch: options.host_arch.clone(),
481        developer_dir,
482        xcode_version,
483        simulator_sdk,
484        device_sdk,
485        simulator_runtimes,
486        installed_rust_targets,
487        host_tools: tools,
488        executable_search_paths: search_paths,
489    })
490}
491
492fn discover_device_sdk(
493    xcrun: &Utf8Path,
494    current_dir: &Utf8Path,
495    developer_environment: Option<(&str, &str)>,
496) -> Option<IosDeviceSdk> {
497    let path = probe_text(
498        "locate physical-iPhone SDK",
499        xcrun,
500        &["--sdk", "iphoneos", "--show-sdk-path"],
501        current_dir,
502        developer_environment,
503    )
504    .map(Utf8PathBuf::from)
505    .filter(|path| path.is_absolute())?
506    .canonicalize_utf8()
507    .ok()
508    .filter(|path| path.is_dir())?;
509    let version = probe_text(
510        "read physical-iPhone SDK version",
511        xcrun,
512        &["--sdk", "iphoneos", "--show-sdk-version"],
513        current_dir,
514        developer_environment,
515    )?;
516    let build_version = probe_text(
517        "read physical-iPhone SDK build version",
518        xcrun,
519        &["--sdk", "iphoneos", "--show-sdk-build-version"],
520        current_dir,
521        developer_environment,
522    )?;
523    Some(IosDeviceSdk {
524        path,
525        version,
526        build_version,
527    })
528}
529
530fn discover_simulator_sdk(
531    xcrun: &Utf8Path,
532    current_dir: &Utf8Path,
533    developer_environment: Option<(&str, &str)>,
534) -> Option<SimulatorSdk> {
535    let path = probe_text(
536        "locate iPhone Simulator SDK",
537        xcrun,
538        &["--sdk", "iphonesimulator", "--show-sdk-path"],
539        current_dir,
540        developer_environment,
541    )
542    .map(Utf8PathBuf::from)
543    .filter(|path| path.is_dir())?;
544    let version = probe_text(
545        "read iPhone Simulator SDK version",
546        xcrun,
547        &["--sdk", "iphonesimulator", "--show-sdk-version"],
548        current_dir,
549        developer_environment,
550    )?;
551    let build_version = probe_text(
552        "read iPhone Simulator SDK build version",
553        xcrun,
554        &["--sdk", "iphonesimulator", "--show-sdk-build-version"],
555        current_dir,
556        developer_environment,
557    );
558    Some(SimulatorSdk {
559        path,
560        version,
561        build_version,
562    })
563}
564
565fn parse_runtimes(source: &str) -> Vec<SimulatorRuntime> {
566    let Ok(value) = serde_json::from_str::<serde_json::Value>(source) else {
567        return Vec::new();
568    };
569    let mut runtimes = value
570        .get("runtimes")
571        .and_then(serde_json::Value::as_array)
572        .into_iter()
573        .flatten()
574        .filter_map(|runtime| {
575            Some(SimulatorRuntime {
576                identifier: runtime.get("identifier")?.as_str()?.to_owned(),
577                name: runtime.get("name")?.as_str()?.to_owned(),
578                version: runtime
579                    .get("version")
580                    .and_then(serde_json::Value::as_str)
581                    .unwrap_or_default()
582                    .to_owned(),
583                available: runtime
584                    .get("isAvailable")
585                    .and_then(serde_json::Value::as_bool)
586                    .unwrap_or(false),
587            })
588        })
589        .collect::<Vec<_>>();
590    runtimes.sort_by(|left, right| left.identifier.cmp(&right.identifier));
591    runtimes
592}
593
594fn has_available_ios_runtime(runtimes: &[SimulatorRuntime]) -> bool {
595    runtimes.iter().any(|runtime| {
596        runtime.available
597            && runtime
598                .identifier
599                .starts_with("com.apple.CoreSimulator.SimRuntime.iOS-")
600    })
601}
602
603fn probe_text(
604    stage: &str,
605    program: &Utf8Path,
606    args: &[&str],
607    current_dir: &Utf8Path,
608    environment: Option<(&str, &str)>,
609) -> Option<String> {
610    let mut spec = CommandSpec::new(stage, program, current_dir);
611    spec.args = args.iter().map(|argument| (*argument).to_owned()).collect();
612    spec.timeout_seconds = 30;
613    if let Some((name, value)) = environment {
614        spec.environment.insert(name.to_owned(), value.to_owned());
615    }
616    let output = run_command(&spec, None).ok()?;
617    let value = String::from_utf8(output.stdout).ok()?;
618    let trimmed = value.trim();
619    (!trimmed.is_empty()).then(|| trimmed.to_owned())
620}
621
622fn find_executable(name: &str, search_paths: &[Utf8PathBuf]) -> Option<Utf8PathBuf> {
623    search_paths
624        .iter()
625        .map(|directory| directory.join(name))
626        .find(|candidate| candidate.is_file())
627}
628
629fn is_full_xcode(developer_dir: &Utf8Path) -> bool {
630    developer_dir
631        .join("Platforms/iPhoneSimulator.platform")
632        .is_dir()
633        && (developer_dir.join("SDKs").is_dir()
634            || developer_dir
635                .join("Platforms/iPhoneSimulator.platform/Developer/SDKs")
636                .is_dir())
637}
638
639fn is_full_device_xcode(developer_dir: &Utf8Path) -> bool {
640    developer_dir.is_absolute()
641        && developer_dir.is_dir()
642        && developer_dir.join("Platforms/iPhoneOS.platform").is_dir()
643        && developer_dir
644            .join("Platforms/iPhoneOS.platform/Developer/SDKs")
645            .is_dir()
646}
647
648fn canonical_device_sdk(sdk: &IosDeviceSdk, developer_dir: &Utf8Path) -> Option<IosDeviceSdk> {
649    if sdk.version.trim().is_empty() || sdk.build_version.trim().is_empty() {
650        return None;
651    }
652    let path = sdk.path.canonicalize_utf8().ok()?;
653    if !path.is_dir() || !path.starts_with(developer_dir) {
654        return None;
655    }
656    Some(IosDeviceSdk {
657        path,
658        version: sdk.version.clone(),
659        build_version: sdk.build_version.clone(),
660    })
661}
662
663fn require_tool(
664    name: &str,
665    value: Option<&Utf8PathBuf>,
666    searched: &[Utf8PathBuf],
667    fix: &str,
668) -> Result<Utf8PathBuf, AppleError> {
669    value.cloned().ok_or_else(|| AppleError::ToolMissing {
670        tool: name.to_owned(),
671        searched: searched
672            .iter()
673            .map(|directory| directory.join(name))
674            .collect(),
675        fix: fix.to_owned(),
676    })
677}
678
679fn require_absolute_tool(
680    name: &str,
681    value: Option<&Utf8PathBuf>,
682    searched: &[Utf8PathBuf],
683    fix: &str,
684) -> Result<Utf8PathBuf, AppleError> {
685    value
686        .filter(|path| path.is_absolute() && is_executable_file(path))
687        .cloned()
688        .ok_or_else(|| AppleError::ToolMissing {
689            tool: name.to_owned(),
690            searched: searched
691                .iter()
692                .map(|directory| directory.join(name))
693                .collect(),
694            fix: fix.to_owned(),
695        })
696}
697
698fn is_executable_file(path: &Utf8Path) -> bool {
699    let Ok(metadata) = path.metadata() else {
700        return false;
701    };
702    if !metadata.is_file() {
703        return false;
704    }
705    #[cfg(unix)]
706    {
707        use std::os::unix::fs::PermissionsExt as _;
708        metadata.permissions().mode() & 0o111 != 0
709    }
710    #[cfg(not(unix))]
711    true
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn executable(path: &Utf8Path) {
719        std::fs::write(path, "fixture").unwrap();
720        #[cfg(unix)]
721        {
722            use std::os::unix::fs::PermissionsExt as _;
723
724            let mut permissions = path.metadata().unwrap().permissions();
725            permissions.set_mode(0o755);
726            std::fs::set_permissions(path, permissions).unwrap();
727        }
728    }
729
730    fn device_discovery_fixture() -> (tempfile::TempDir, AppleDiscovery) {
731        let temporary = tempfile::tempdir().unwrap();
732        let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
733        let developer_dir = root.join("Xcode.app/Contents/Developer");
734        let sdk = developer_dir.join("Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk");
735        std::fs::create_dir_all(&sdk).unwrap();
736        let bin = root.join("bin");
737        std::fs::create_dir(&bin).unwrap();
738        for tool in ["cargo", "rustup", "xcodebuild", "xcrun", "plutil"] {
739            executable(&bin.join(tool));
740        }
741        let discovery = AppleDiscovery {
742            host_os: "macos".to_owned(),
743            host_arch: "arm64".to_owned(),
744            developer_dir: Some(developer_dir),
745            xcode_version: Some("Xcode 26.0\nBuild version 23A1".to_owned()),
746            simulator_sdk: None,
747            device_sdk: Some(IosDeviceSdk {
748                path: sdk,
749                version: "26.0".to_owned(),
750                build_version: "23A1".to_owned(),
751            }),
752            simulator_runtimes: Vec::new(),
753            installed_rust_targets: vec![IOS_DEVICE_TARGET.to_owned()],
754            host_tools: AppleHostTools {
755                cargo: Some(bin.join("cargo")),
756                rustup: Some(bin.join("rustup")),
757                xcode_select: None,
758                xcodebuild: Some(bin.join("xcodebuild")),
759                xcrun: Some(bin.join("xcrun")),
760                plutil: Some(bin.join("plutil")),
761            },
762            executable_search_paths: vec![bin],
763        };
764        (temporary, discovery)
765    }
766
767    #[test]
768    fn parses_available_and_unavailable_runtimes() {
769        let source = r#"{"runtimes":[{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-18-0","name":"iOS 18","version":"18.0","isAvailable":false},{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-17-5","name":"iOS 17","version":"17.5","isAvailable":true}]}"#;
770        let runtimes = parse_runtimes(source);
771        assert_eq!(
772            runtimes[0].identifier,
773            "com.apple.CoreSimulator.SimRuntime.iOS-17-5"
774        );
775        assert!(runtimes[0].available);
776        assert!(!runtimes[1].available);
777        assert!(has_available_ios_runtime(&runtimes));
778    }
779
780    #[test]
781    fn non_ios_and_unavailable_runtimes_do_not_enable_asset_compilation() {
782        let runtimes = parse_runtimes(
783            r#"{"runtimes":[{"identifier":"com.apple.CoreSimulator.SimRuntime.tvOS-26-0","name":"tvOS 26","version":"26.0","isAvailable":true},{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-26-0","name":"iOS 26","version":"26.0","isAvailable":false}]}"#,
784        );
785        assert!(!has_available_ios_runtime(&runtimes));
786    }
787
788    #[test]
789    fn command_discovery_does_not_interpret_shell_characters() {
790        use std::fs;
791
792        let directory = tempfile::tempdir().unwrap();
793        let directory = Utf8Path::from_path(directory.path()).unwrap();
794        fs::write(directory.join("cargo;echo-pwned"), "not executable").unwrap();
795        assert!(find_executable("cargo", &[directory.to_owned()]).is_none());
796    }
797
798    #[test]
799    fn device_selection_is_independent_of_simulator_sdk_and_target() {
800        let (_temporary, discovery) = device_discovery_fixture();
801
802        let selected = discovery.select_device_toolchain().unwrap();
803
804        assert_eq!(selected.device_sdk.version, "26.0");
805        assert_eq!(selected.device_sdk.build_version, "23A1");
806        assert!(selected.device_sdk.path.is_absolute());
807        assert!(
808            selected
809                .device_sdk
810                .path
811                .starts_with(&selected.developer_dir)
812        );
813        assert_eq!(selected.host_arch, "arm64");
814        for tool in [
815            &selected.cargo,
816            &selected.rustup,
817            &selected.xcodebuild,
818            &selected.xcrun,
819            &selected.plutil,
820        ] {
821            assert!(tool.is_absolute());
822        }
823    }
824
825    #[test]
826    fn device_selection_requires_exact_physical_rust_target() {
827        let (_temporary, mut discovery) = device_discovery_fixture();
828        discovery.installed_rust_targets = vec![IOS_SIMULATOR_TARGET.to_owned()];
829
830        assert!(matches!(
831            discovery.select_device_toolchain(),
832            Err(AppleError::RustTargetMissing { target }) if target == IOS_DEVICE_TARGET
833        ));
834    }
835
836    #[test]
837    fn device_selection_distinguishes_missing_iphoneos_sdk() {
838        let (_temporary, mut discovery) = device_discovery_fixture();
839        discovery.device_sdk = None;
840
841        assert!(matches!(
842            discovery.select_device_toolchain(),
843            Err(AppleError::InvalidRequest(message)) if message.contains("iPhoneOS SDK")
844        ));
845    }
846
847    #[test]
848    fn device_selection_rejects_sdk_from_another_developer_directory() {
849        let (_temporary, mut discovery) = device_discovery_fixture();
850        let developer_dir = discovery.developer_dir.as_ref().unwrap();
851        let outside = developer_dir
852            .parent()
853            .unwrap()
854            .join("OtherXcode/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk");
855        std::fs::create_dir_all(&outside).unwrap();
856        discovery.device_sdk.as_mut().unwrap().path = outside;
857
858        assert!(matches!(
859            discovery.select_device_toolchain(),
860            Err(AppleError::InvalidRequest(message)) if message.contains("iPhoneOS SDK")
861        ));
862    }
863
864    #[test]
865    fn device_selection_rejects_relative_tool_paths() {
866        let (_temporary, mut discovery) = device_discovery_fixture();
867        discovery.host_tools.cargo = Some(Utf8PathBuf::from("bin/cargo"));
868
869        assert!(matches!(
870            discovery.select_device_toolchain(),
871            Err(AppleError::ToolMissing { tool, .. }) if tool == "cargo"
872        ));
873    }
874}