Skip to main content

rustferry_android/
doctor.rs

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/// Severity and result of one doctor check.
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum DoctorStatus {
18    /// Required or optional component is available.
19    Passed,
20    /// Optional component is absent or setup will happen lazily.
21    Warning,
22    /// Required build component is absent or invalid.
23    Failed,
24}
25
26/// One stable, machine-readable Android doctor finding.
27#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
28pub struct DoctorCheck {
29    /// Stable check identifier.
30    pub id: String,
31    /// User-facing component name.
32    pub name: String,
33    /// Check outcome.
34    pub status: DoctorStatus,
35    /// Found version/path or missing-component explanation.
36    pub detail: String,
37    /// Concrete remediation when action is useful.
38    pub fix: Option<String>,
39}
40
41/// Android doctor inputs.
42#[derive(Clone, Debug, Default, PartialEq)]
43pub struct DoctorOptions {
44    /// Read-only discovery configuration.
45    pub discovery: DiscoveryOptions,
46    /// Android settings whose target and ABIs should be checked.
47    pub android: AndroidConfig,
48    /// Override for the machine-local cargo-ferry configuration directory.
49    pub config_dir: Option<Utf8PathBuf>,
50}
51
52/// Complete Android doctor report. Generating it never installs or changes tools.
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54pub struct DoctorReport {
55    /// Stable report schema version.
56    pub schema_version: u32,
57    /// Whether every required direct-build check passed.
58    pub ready_for_build: bool,
59    /// Ordered checks.
60    pub checks: Vec<DoctorCheck>,
61    /// Raw discovery inventory used to make the report.
62    pub discovery: AndroidDiscovery,
63}
64
65/// Inspect the host for every direct Android pipeline prerequisite.
66pub 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}