Skip to main content

rustferry_apple/
doctor.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{AppleDiscovery, AppleDiscoveryOptions, IOS_SIMULATOR_TARGET, discover_apple};
4
5/// Severity and result of one Apple doctor check.
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "kebab-case")]
8pub enum DoctorStatus {
9    /// Required or optional component is available.
10    Passed,
11    /// Optional runtime is absent or a non-blocking limitation was found.
12    Warning,
13    /// A required build-only component is absent or invalid.
14    Failed,
15}
16
17/// One stable, machine-readable Apple doctor finding.
18#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
19pub struct AppleDoctorCheck {
20    /// Stable check identifier.
21    pub id: String,
22    /// User-facing component name.
23    pub name: String,
24    /// Check outcome.
25    pub status: DoctorStatus,
26    /// Found version/path or missing-component explanation.
27    pub detail: String,
28    /// Concrete remediation when action is useful.
29    pub fix: Option<String>,
30}
31
32/// Apple doctor inputs.
33#[derive(Clone, Debug, Default, Eq, PartialEq)]
34pub struct AppleDoctorOptions {
35    /// Read-only discovery configuration.
36    pub discovery: AppleDiscoveryOptions,
37}
38
39/// Complete Apple doctor report. Generating it never installs or changes tools.
40#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
41pub struct AppleDoctorReport {
42    /// Stable report schema version.
43    pub schema_version: u32,
44    /// Whether every component required for a build-only simulator artifact is present.
45    pub ready_for_simulator_build: bool,
46    /// Whether a usable Simulator runtime is present for later install/run commands.
47    pub ready_for_simulator_run: bool,
48    /// Ordered checks.
49    pub checks: Vec<AppleDoctorCheck>,
50    /// Raw inventory, absent only when discovery itself could not start.
51    pub discovery: Option<AppleDiscovery>,
52}
53
54/// Inspect every iOS Simulator build prerequisite without modifying the system.
55#[allow(clippy::too_many_lines)]
56pub fn doctor_apple(options: &AppleDoctorOptions) -> AppleDoctorReport {
57    let discovery = match discover_apple(&options.discovery) {
58        Ok(discovery) => discovery,
59        Err(error) => {
60            return AppleDoctorReport {
61                schema_version: 1,
62                ready_for_simulator_build: false,
63                ready_for_simulator_run: false,
64                checks: vec![fail(
65                    "discovery",
66                    "Apple toolchain discovery",
67                    error.to_string(),
68                    "Use an existing project directory and rerun `cargo ferry doctor`.",
69                )],
70                discovery: None,
71            };
72        }
73    };
74
75    let mut checks = Vec::new();
76    if discovery.host_os == "macos" {
77        checks.push(pass(
78            "host",
79            "Build host",
80            format!("macOS ({})", discovery.host_arch),
81        ));
82    } else {
83        checks.push(fail(
84            "host",
85            "Build host",
86            discovery.host_os.clone(),
87            "Build iOS applications on macOS with full Xcode installed.",
88        ));
89    }
90
91    match (&discovery.developer_dir, &discovery.xcode_version) {
92        (Some(path), Some(version))
93            if path
94                .join("Platforms/iPhoneSimulator.platform/Developer/SDKs")
95                .is_dir() =>
96        {
97            checks.push(pass(
98                "xcode",
99                "Full Xcode",
100                format!("{} ({path})", version.replace('\n', ", ")),
101            ));
102        }
103        (path, version) => checks.push(fail(
104            "xcode",
105            "Full Xcode",
106            format!("developer directory: {path:?}; version: {version:?}"),
107            "Install Xcode from Apple, select it with xcode-select, then rerun `cargo ferry doctor`.",
108        )),
109    }
110
111    executable_check(
112        &mut checks,
113        "xcodebuild",
114        "xcodebuild",
115        discovery.host_tools.xcodebuild.as_ref(),
116        "Install full Xcode and select its developer directory.",
117    );
118    executable_check(
119        &mut checks,
120        "xcrun",
121        "xcrun",
122        discovery.host_tools.xcrun.as_ref(),
123        "Install full Xcode and select its developer directory.",
124    );
125    executable_check(
126        &mut checks,
127        "plutil",
128        "plutil",
129        discovery.host_tools.plutil.as_ref(),
130        "Restore the macOS command-line tools or reinstall Xcode.",
131    );
132    executable_check(
133        &mut checks,
134        "cargo",
135        "Cargo",
136        discovery.host_tools.cargo.as_ref(),
137        "Install Rust with rustup.",
138    );
139    executable_check(
140        &mut checks,
141        "rustup",
142        "rustup",
143        discovery.host_tools.rustup.as_ref(),
144        "Install rustup so iOS Rust targets can be managed.",
145    );
146
147    if let Some(sdk) = &discovery.simulator_sdk {
148        checks.push(pass(
149            "iphonesimulator-sdk",
150            "iPhone Simulator SDK",
151            format!("{} ({})", sdk.version, sdk.path),
152        ));
153    } else {
154        checks.push(fail(
155            "iphonesimulator-sdk",
156            "iPhone Simulator SDK",
157            "xcrun did not return an installed SDK".to_owned(),
158            "Open Xcode Settings > Platforms and install an iOS platform SDK.",
159        ));
160    }
161
162    if discovery
163        .installed_rust_targets
164        .iter()
165        .any(|target| target == IOS_SIMULATOR_TARGET)
166    {
167        checks.push(pass(
168            "rust-target-ios-simulator",
169            format!("Rust target {IOS_SIMULATOR_TARGET}"),
170            "installed".to_owned(),
171        ));
172    } else {
173        checks.push(fail(
174            "rust-target-ios-simulator",
175            format!("Rust target {IOS_SIMULATOR_TARGET}"),
176            "not installed".to_owned(),
177            format!("Run `rustup target add {IOS_SIMULATOR_TARGET}`."),
178        ));
179    }
180
181    let available_runtimes = discovery
182        .simulator_runtimes
183        .iter()
184        .filter(|runtime| runtime.available)
185        .collect::<Vec<_>>();
186    let ready_for_simulator_run = !available_runtimes.is_empty();
187    if ready_for_simulator_run {
188        checks.push(pass(
189            "simulator-runtime",
190            "Simulator runtime",
191            available_runtimes
192                .iter()
193                .map(|runtime| format!("{} {}", runtime.name, runtime.version))
194                .collect::<Vec<_>>()
195                .join(", "),
196        ));
197    } else {
198        checks.push(warn(
199            "simulator-runtime",
200            "Simulator runtime",
201            "no available runtime; build-only remains supported".to_owned(),
202            Some(
203                "Install a runtime in Xcode Settings > Platforms only for install/run validation."
204                    .to_owned(),
205            ),
206        ));
207    }
208
209    if discovery.host_arch != "aarch64" {
210        checks.push(warn(
211            "host-architecture",
212            "Simulator host architecture",
213            format!(
214                "{} host; cargo-ferry currently emits an arm64 simulator executable",
215                discovery.host_arch
216            ),
217            Some(
218                "Build-only can cross-compile, but run validation requires an Apple Silicon Mac."
219                    .to_owned(),
220            ),
221        ));
222    }
223
224    let ready_for_simulator_build = checks
225        .iter()
226        .all(|check| check.status != DoctorStatus::Failed);
227    AppleDoctorReport {
228        schema_version: 1,
229        ready_for_simulator_build,
230        ready_for_simulator_run,
231        checks,
232        discovery: Some(discovery),
233    }
234}
235
236fn executable_check(
237    checks: &mut Vec<AppleDoctorCheck>,
238    id: &str,
239    name: &str,
240    path: Option<&camino::Utf8PathBuf>,
241    fix: &str,
242) {
243    if let Some(path) = path {
244        checks.push(pass(id, name, path.to_string()));
245    } else {
246        checks.push(fail(id, name, "not found".to_owned(), fix.to_owned()));
247    }
248}
249
250fn pass(
251    id: impl Into<String>,
252    name: impl Into<String>,
253    detail: impl Into<String>,
254) -> AppleDoctorCheck {
255    AppleDoctorCheck {
256        id: id.into(),
257        name: name.into(),
258        status: DoctorStatus::Passed,
259        detail: detail.into(),
260        fix: None,
261    }
262}
263
264fn warn(
265    id: impl Into<String>,
266    name: impl Into<String>,
267    detail: impl Into<String>,
268    fix: Option<String>,
269) -> AppleDoctorCheck {
270    AppleDoctorCheck {
271        id: id.into(),
272        name: name.into(),
273        status: DoctorStatus::Warning,
274        detail: detail.into(),
275        fix,
276    }
277}
278
279fn fail(
280    id: impl Into<String>,
281    name: impl Into<String>,
282    detail: impl Into<String>,
283    fix: impl Into<String>,
284) -> AppleDoctorCheck {
285    AppleDoctorCheck {
286        id: id.into(),
287        name: name.into(),
288        status: DoctorStatus::Failed,
289        detail: detail.into(),
290        fix: Some(fix.into()),
291    }
292}