1use std::time::Duration;
2
3use camino::Utf8PathBuf;
4use rustferry_core::AndroidConfig;
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 AndroidDiscovery, CommandSpec, DiscoveryOptions, command::run_probe_command, discover_android,
9 signing::default_debug_signing_paths,
10};
11
12const DOCTOR_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum DoctorStatus {
18 Passed,
20 Warning,
22 Failed,
24}
25
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
28pub struct DoctorCheck {
29 pub id: String,
31 pub name: String,
33 pub status: DoctorStatus,
35 pub detail: String,
37 pub fix: Option<String>,
39}
40
41#[derive(Clone, Debug, Default, PartialEq)]
43pub struct DoctorOptions {
44 pub discovery: DiscoveryOptions,
46 pub android: AndroidConfig,
48 pub config_dir: Option<Utf8PathBuf>,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54pub struct DoctorReport {
55 pub schema_version: u32,
57 pub ready_for_build: bool,
59 pub checks: Vec<DoctorCheck>,
61 pub discovery: AndroidDiscovery,
63}
64
65pub fn doctor_android(options: &DoctorOptions) -> DoctorReport {
67 let discovery = discover_android(&options.discovery);
68 let mut checks = Vec::new();
69
70 executable_check(
71 &mut checks,
72 "rustc",
73 "Rust compiler",
74 discovery.host_tools.rustc.as_ref(),
75 true,
76 "Install Rust with rustup.",
77 );
78 executable_check(
79 &mut checks,
80 "cargo",
81 "Cargo",
82 discovery.host_tools.cargo.as_ref(),
83 true,
84 "Install Rust with rustup.",
85 );
86 executable_check(
87 &mut checks,
88 "rustup",
89 "rustup",
90 discovery.host_tools.rustup.as_ref(),
91 true,
92 "Install rustup so Android Rust targets can be managed.",
93 );
94 rust_target_checks(&mut checks, &discovery, &options.android);
95
96 let sdk_root = discovery
97 .sdk_roots_searched
98 .iter()
99 .find(|root| root.is_dir());
100 if let Some(root) = sdk_root {
101 checks.push(pass("android-sdk", "Android SDK", root.to_string()));
102 let platform_tools = root.join("platform-tools");
103 if platform_tools.is_dir() {
104 checks.push(pass(
105 "platform-tools",
106 "Android SDK Platform Tools",
107 platform_tools.to_string(),
108 ));
109 } else {
110 checks.push(warn(
111 "platform-tools",
112 "Android SDK Platform Tools",
113 "not installed; not required to build",
114 Some("Install Platform Tools only for device deployment.".to_owned()),
115 ));
116 }
117 } else {
118 checks.push(fail(
119 "android-sdk",
120 "Android SDK",
121 format!("searched {:?}", discovery.sdk_roots_searched),
122 "Install the Android SDK or set ANDROID_SDK_ROOT.".to_owned(),
123 ));
124 }
125
126 let selected_platform = select_doctor_platform(&discovery, &options.android.target_sdk);
127 if let Some(platform) = selected_platform {
128 checks.push(pass(
129 "android-platform",
130 "Android platform",
131 format!("android-{} ({})", platform.api_level, platform.android_jar),
132 ));
133 } else {
134 checks.push(fail(
135 "android-platform",
136 "Android platform",
137 format!(
138 "requested `{}`; searched {:?}",
139 options.android.target_sdk, discovery.sdk_roots_searched
140 ),
141 format!(
142 "Install `platforms;android-{}` with sdkmanager.",
143 options.android.target_sdk.trim_start_matches("android-")
144 ),
145 ));
146 }
147
148 if let Some(tools) = discovery.build_tools.iter().rev().find(|tools| {
149 tools.is_complete()
150 && selected_platform.is_none_or(|platform| tools.sdk_root == platform.sdk_root)
151 }) {
152 checks.push(pass(
153 "build-tools",
154 "Android SDK Build Tools",
155 format!("{} ({})", tools.version, tools.directory),
156 ));
157 for (id, name, path) in [
158 ("aapt2", "aapt2", tools.aapt2.as_ref()),
159 ("d8", "d8", tools.d8.as_ref()),
160 ("zipalign", "zipalign", tools.zipalign.as_ref()),
161 ("apksigner", "apksigner", tools.apksigner.as_ref()),
162 ] {
163 executable_check(
164 &mut checks,
165 id,
166 name,
167 path,
168 true,
169 "Install a complete Android SDK Build Tools revision.",
170 );
171 }
172 } else {
173 checks.push(fail(
174 "build-tools",
175 "Android SDK Build Tools",
176 format!("searched {:?}", discovery.sdk_roots_searched),
177 "Install `build-tools;<version>` with sdkmanager.".to_owned(),
178 ));
179 }
180
181 if let Some(ndk) = discovery
182 .ndks
183 .iter()
184 .rev()
185 .find(|ndk| ndk.llvm_prebuilt.is_some())
186 {
187 checks.push(pass(
188 "ndk",
189 "Android NDK",
190 format!("{} ({})", ndk.version, ndk.root),
191 ));
192 for abi in &options.android.abis {
193 let id = format!("ndk-linker-{}", abi.apk_directory());
194 let name = format!("NDK linker ({})", abi.rust_target());
195 let linker = ndk.linker_for(*abi, options.android.min_sdk);
196 match linker {
197 Ok(path) => checks.push(pass(&id, &name, path.to_string())),
198 Err(error) => checks.push(fail(
199 &id,
200 &name,
201 error.to_string(),
202 "Install a complete Android NDK and rerun `cargo ferry doctor`.".to_owned(),
203 )),
204 }
205 }
206 match ndk.llvm_ar() {
207 Ok(path) => checks.push(pass("ndk-llvm-ar", "NDK LLVM archiver", path.to_string())),
208 Err(error) => checks.push(fail(
209 "ndk-llvm-ar",
210 "NDK LLVM archiver",
211 error.to_string(),
212 "Reinstall the selected Android NDK.".to_owned(),
213 )),
214 }
215 } else {
216 checks.push(fail(
217 "ndk",
218 "Android NDK",
219 format!("searched {:?}", discovery.ndk_roots_searched),
220 "Install `ndk;<version>` with sdkmanager.".to_owned(),
221 ));
222 }
223
224 executable_check(
225 &mut checks,
226 "java",
227 "Java runtime",
228 discovery.host_tools.java.as_ref(),
229 true,
230 "Install a JDK and set JAVA_HOME.",
231 );
232 executable_check(
233 &mut checks,
234 "javac",
235 "Java compiler",
236 discovery.host_tools.javac.as_ref(),
237 true,
238 "Install a JDK and set JAVA_HOME; the generated Android bridge requires javac.",
239 );
240 executable_check(
241 &mut checks,
242 "keytool",
243 "Debug keystore tool",
244 discovery.host_tools.keytool.as_ref(),
245 true,
246 "Install a JDK and set JAVA_HOME.",
247 );
248 executable_check(
249 &mut checks,
250 "sdkmanager",
251 "Android SDK manager",
252 discovery.host_tools.sdkmanager.as_ref(),
253 false,
254 "Install Android SDK Command-line Tools to manage missing components.",
255 );
256 executable_check(
257 &mut checks,
258 "adb",
259 "Android Debug Bridge",
260 discovery.host_tools.adb.as_ref(),
261 false,
262 "Install Android SDK Platform Tools only if install/run commands are needed.",
263 );
264 executable_check(
265 &mut checks,
266 "emulator",
267 "Android emulator",
268 discovery.host_tools.emulator.as_ref(),
269 false,
270 "Install the Android emulator only for runtime validation; builds do not need it.",
271 );
272
273 match default_debug_signing_paths(options.config_dir.as_deref()) {
274 Ok(paths) if paths.keystore.is_file() && paths.password_file.is_file() => {
275 checks.push(pass(
276 "debug-keystore",
277 "Persistent debug keystore",
278 paths.keystore.to_string(),
279 ));
280 }
281 Ok(paths) => checks.push(warn(
282 "debug-keystore",
283 "Persistent debug keystore",
284 format!("not created yet; build will create {}", paths.keystore),
285 None,
286 )),
287 Err(error) => checks.push(fail(
288 "debug-keystore",
289 "Persistent debug keystore",
290 error.to_string(),
291 "Set a writable cargo-ferry config directory.".to_owned(),
292 )),
293 }
294
295 let ready_for_build = checks
296 .iter()
297 .all(|check| check.status != DoctorStatus::Failed);
298 DoctorReport {
299 schema_version: 1,
300 ready_for_build,
301 checks,
302 discovery,
303 }
304}
305
306fn rust_target_checks(
307 checks: &mut Vec<DoctorCheck>,
308 discovery: &AndroidDiscovery,
309 config: &AndroidConfig,
310) {
311 let installed = discovery
312 .host_tools
313 .rustup
314 .as_ref()
315 .and_then(|rustup| probe_output(rustup, ["target", "list", "--installed"]))
316 .filter(|output| output.status.success())
317 .map(|output| String::from_utf8_lossy(&output.stdout).into_owned());
318 for abi in &config.abis {
319 let target = abi.rust_target();
320 if installed
321 .as_deref()
322 .is_some_and(|output| output.lines().any(|line| line.trim() == target))
323 {
324 checks.push(pass(
325 format!("rust-target-{}", abi.apk_directory()),
326 format!("Rust target {target}"),
327 "installed",
328 ));
329 } else {
330 checks.push(fail(
331 format!("rust-target-{}", abi.apk_directory()),
332 format!("Rust target {target}"),
333 "not installed",
334 format!("Run `rustup target add {target}`."),
335 ));
336 }
337 }
338}
339
340fn executable_check(
341 checks: &mut Vec<DoctorCheck>,
342 id: &str,
343 name: &str,
344 path: Option<&Utf8PathBuf>,
345 required: bool,
346 fix: &str,
347) {
348 if let Some(path) = path {
349 let detail = probe_version(path).unwrap_or_else(|| path.to_string());
350 checks.push(pass(id, name, detail));
351 } else if required {
352 checks.push(fail(id, name, "not found", fix.to_owned()));
353 } else {
354 checks.push(warn(id, name, "not found", Some(fix.to_owned())));
355 }
356}
357
358fn probe_version(path: &Utf8PathBuf) -> Option<String> {
359 let output = probe_output(path, ["--version"])?;
360 let text = if output.stdout.is_empty() {
361 &output.stderr
362 } else {
363 &output.stdout
364 };
365 let version = String::from_utf8_lossy(text)
366 .lines()
367 .next()?
368 .trim()
369 .to_owned();
370 (!version.is_empty()).then_some(version)
371}
372
373fn probe_output<const N: usize>(
374 path: &Utf8PathBuf,
375 arguments: [&str; N],
376) -> Option<crate::CommandOutput> {
377 let current_dir = path.parent().unwrap_or_else(|| camino::Utf8Path::new("."));
378 let mut command = CommandSpec::new(
379 format!("probe {}", path.file_name().unwrap_or("tool")),
380 path,
381 current_dir,
382 );
383 command.args = arguments.into_iter().map(ToOwned::to_owned).collect();
384 command.timeout = DOCTOR_PROBE_TIMEOUT;
385 run_probe_command(&command).ok()
386}
387
388fn select_doctor_platform<'a>(
389 discovery: &'a AndroidDiscovery,
390 requested: &str,
391) -> Option<&'a crate::AndroidPlatform> {
392 if requested == "installed" {
393 discovery
394 .platforms
395 .iter()
396 .max_by_key(|platform| platform.api_level)
397 } else {
398 let api = requested
399 .trim_start_matches("android-")
400 .parse::<u32>()
401 .ok()?;
402 discovery
403 .platforms
404 .iter()
405 .find(|platform| platform.api_level == api)
406 }
407}
408
409fn pass(id: impl Into<String>, name: impl Into<String>, detail: impl Into<String>) -> DoctorCheck {
410 DoctorCheck {
411 id: id.into(),
412 name: name.into(),
413 status: DoctorStatus::Passed,
414 detail: detail.into(),
415 fix: None,
416 }
417}
418
419fn warn(
420 id: impl Into<String>,
421 name: impl Into<String>,
422 detail: impl Into<String>,
423 fix: Option<String>,
424) -> DoctorCheck {
425 DoctorCheck {
426 id: id.into(),
427 name: name.into(),
428 status: DoctorStatus::Warning,
429 detail: detail.into(),
430 fix,
431 }
432}
433
434fn fail(
435 id: impl Into<String>,
436 name: impl Into<String>,
437 detail: impl Into<String>,
438 fix: impl Into<String>,
439) -> DoctorCheck {
440 DoctorCheck {
441 id: id.into(),
442 name: name.into(),
443 status: DoctorStatus::Failed,
444 detail: detail.into(),
445 fix: Some(fix.into()),
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn optional_device_tools_do_not_block_build_readiness() {
455 let checks = [
456 pass("required", "Required", "present"),
457 warn("adb", "ADB", "not found", None),
458 ];
459 assert!(
460 checks
461 .iter()
462 .all(|check| check.status != DoctorStatus::Failed)
463 );
464 }
465
466 #[test]
467 fn mandatory_javac_blocks_build_readiness() {
468 let mut checks = Vec::new();
469 executable_check(
470 &mut checks,
471 "javac",
472 "Java compiler",
473 None,
474 true,
475 "Install a JDK.",
476 );
477 assert_eq!(checks[0].status, DoctorStatus::Failed);
478 }
479}