Skip to main content

rustferry_android/
discovery.rs

1use std::{collections::BTreeSet, env, fs, path::PathBuf};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rustferry_core::{AndroidAbi, AndroidConfig};
5use serde::{Deserialize, Serialize};
6
7use crate::AndroidError;
8
9/// Explicit discovery inputs. [`Default`] reads only conventional path environment variables.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct DiscoveryOptions {
12    /// Preferred Android SDK root.
13    pub sdk_root: Option<Utf8PathBuf>,
14    /// Preferred Android NDK root.
15    pub ndk_root: Option<Utf8PathBuf>,
16    /// Preferred Java home.
17    pub java_home: Option<Utf8PathBuf>,
18    /// Directories searched for host executables.
19    pub executable_search_paths: Vec<Utf8PathBuf>,
20    /// Home directory used for OS-standard SDK locations.
21    pub home_dir: Option<Utf8PathBuf>,
22    /// NDK prebuilt host tag override, useful for deterministic tests.
23    pub host_tag: Option<String>,
24}
25
26impl DiscoveryOptions {
27    /// Capture standard Android, Java, `PATH`, and home path variables.
28    pub fn from_environment() -> Self {
29        let sdk_root = env_path("ANDROID_SDK_ROOT").or_else(|| env_path("ANDROID_HOME"));
30        let ndk_root = env_path("ANDROID_NDK_HOME").or_else(|| env_path("ANDROID_NDK_ROOT"));
31        let java_home = env_path("JAVA_HOME");
32        let executable_search_paths = env::var_os("PATH")
33            .map(|value| {
34                env::split_paths(&value)
35                    .filter_map(|path| Utf8PathBuf::from_path_buf(path).ok())
36                    .collect()
37            })
38            .unwrap_or_default();
39        Self {
40            sdk_root,
41            ndk_root,
42            java_home,
43            executable_search_paths,
44            home_dir: env_path(home_variable()),
45            host_tag: None,
46        }
47    }
48}
49
50impl Default for DiscoveryOptions {
51    fn default() -> Self {
52        Self::from_environment()
53    }
54}
55
56/// One installed Android SDK platform.
57#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
58pub struct AndroidPlatform {
59    /// Owning SDK root.
60    pub sdk_root: Utf8PathBuf,
61    /// Numeric API level.
62    pub api_level: u32,
63    /// Platform directory.
64    pub directory: Utf8PathBuf,
65    /// Framework resource JAR used by AAPT2 and D8.
66    pub android_jar: Utf8PathBuf,
67}
68
69/// One Android SDK Build Tools installation.
70#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct AndroidBuildTools {
72    /// Owning SDK root.
73    pub sdk_root: Utf8PathBuf,
74    /// SDK directory version.
75    pub version: String,
76    /// Build Tools directory.
77    pub directory: Utf8PathBuf,
78    /// Android Asset Packaging Tool 2.
79    pub aapt2: Option<Utf8PathBuf>,
80    /// D8 bytecode compiler.
81    pub d8: Option<Utf8PathBuf>,
82    /// ZIP alignment tool.
83    pub zipalign: Option<Utf8PathBuf>,
84    /// APK signing tool.
85    pub apksigner: Option<Utf8PathBuf>,
86}
87
88impl AndroidBuildTools {
89    /// Whether all direct-pipeline executables are present.
90    pub fn is_complete(&self) -> bool {
91        self.aapt2.is_some()
92            && self.d8.is_some()
93            && self.zipalign.is_some()
94            && self.apksigner.is_some()
95    }
96}
97
98/// One Android NDK installation and its LLVM host toolchain.
99#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
100pub struct AndroidNdk {
101    /// NDK directory.
102    pub root: Utf8PathBuf,
103    /// NDK revision from `source.properties`, when readable.
104    pub version: String,
105    /// Selected `toolchains/llvm/prebuilt/<host>` directory.
106    pub llvm_prebuilt: Option<Utf8PathBuf>,
107}
108
109impl AndroidNdk {
110    /// Resolve the NDK Clang driver for one ABI and minimum API level.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error when this NDK has no host prebuilt or matching Clang driver.
115    pub fn linker_for(&self, abi: AndroidAbi, min_sdk: u32) -> Result<Utf8PathBuf, AndroidError> {
116        let prebuilt = self
117            .llvm_prebuilt
118            .as_ref()
119            .ok_or_else(|| AndroidError::ToolMissing {
120                tool: "Android NDK LLVM host prebuilt".to_owned(),
121                searched: vec![self.root.join("toolchains/llvm/prebuilt")],
122                fix: "Install an NDK package matching this host.".to_owned(),
123            })?;
124        let stem = match abi {
125            AndroidAbi::Arm64V8a => "aarch64-linux-android",
126            AndroidAbi::X86_64 => "x86_64-linux-android",
127            AndroidAbi::ArmeabiV7a => "armv7a-linux-androideabi",
128        };
129        let candidate = prebuilt
130            .join("bin")
131            .join(format!("{stem}{min_sdk}-clang{}", ndk_clang_suffix()));
132        if candidate.is_file() {
133            Ok(candidate)
134        } else {
135            Err(AndroidError::NdkLinkerMissing {
136                target: abi.rust_target().to_owned(),
137                searched: vec![candidate],
138                fix: format!(
139                    "Install a complete NDK supporting API {min_sdk}, then run `cargo ferry doctor`."
140                ),
141            })
142        }
143    }
144
145    /// Resolve this NDK's LLVM archiver.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error when this NDK lacks its host prebuilt or `llvm-ar`.
150    pub fn llvm_ar(&self) -> Result<Utf8PathBuf, AndroidError> {
151        let prebuilt = self
152            .llvm_prebuilt
153            .as_ref()
154            .ok_or_else(|| AndroidError::ToolMissing {
155                tool: "Android NDK LLVM host prebuilt".to_owned(),
156                searched: vec![self.root.join("toolchains/llvm/prebuilt")],
157                fix: "Install an NDK package matching this host.".to_owned(),
158            })?;
159        let candidate = prebuilt
160            .join("bin")
161            .join(format!("llvm-ar{}", executable_suffix()));
162        if candidate.is_file() {
163            Ok(candidate)
164        } else {
165            Err(AndroidError::ToolMissing {
166                tool: "NDK llvm-ar".to_owned(),
167                searched: vec![candidate],
168                fix: "Reinstall the selected Android NDK, then run `cargo ferry doctor`."
169                    .to_owned(),
170            })
171        }
172    }
173}
174
175/// Optional host and SDK helper executables found during discovery.
176#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
177pub struct AndroidHostTools {
178    /// Cargo executable.
179    pub cargo: Option<Utf8PathBuf>,
180    /// Rust compiler.
181    pub rustc: Option<Utf8PathBuf>,
182    /// Rustup executable.
183    pub rustup: Option<Utf8PathBuf>,
184    /// Java runtime.
185    pub java: Option<Utf8PathBuf>,
186    /// Java compiler.
187    pub javac: Option<Utf8PathBuf>,
188    /// Java key store utility.
189    pub keytool: Option<Utf8PathBuf>,
190    /// Android SDK manager.
191    pub sdkmanager: Option<Utf8PathBuf>,
192    /// Android Debug Bridge; not required to build.
193    pub adb: Option<Utf8PathBuf>,
194    /// Android emulator; not required to build.
195    pub emulator: Option<Utf8PathBuf>,
196}
197
198/// Complete read-only inventory of Android toolchain candidates.
199#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
200pub struct AndroidDiscovery {
201    /// SDK roots inspected.
202    pub sdk_roots_searched: Vec<Utf8PathBuf>,
203    /// NDK roots inspected.
204    pub ndk_roots_searched: Vec<Utf8PathBuf>,
205    /// Installed SDK platforms.
206    pub platforms: Vec<AndroidPlatform>,
207    /// Installed SDK Build Tools, including incomplete directories for diagnostics.
208    pub build_tools: Vec<AndroidBuildTools>,
209    /// Installed NDKs.
210    pub ndks: Vec<AndroidNdk>,
211    /// Host and optional SDK tools.
212    pub host_tools: AndroidHostTools,
213}
214
215impl AndroidDiscovery {
216    /// Select a complete direct-build toolchain for a validated Android configuration.
217    ///
218    /// # Errors
219    ///
220    /// Returns a typed error naming any missing platform, Build Tools, NDK, Cargo, or key tool.
221    pub fn select_toolchain(
222        &self,
223        config: &AndroidConfig,
224    ) -> Result<AndroidToolchain, AndroidError> {
225        let platform = select_platform(&self.platforms, &config.target_sdk)?;
226        if platform.api_level < config.min_sdk {
227            return Err(AndroidError::InvalidRequest(format!(
228                "target SDK {} is below min SDK {}",
229                platform.api_level, config.min_sdk
230            )));
231        }
232        let build_tools = self
233            .build_tools
234            .iter()
235            .filter(|tools| tools.sdk_root == platform.sdk_root && tools.is_complete())
236            .max_by(|left, right| compare_versions(&left.version, &right.version))
237            .cloned()
238            .ok_or_else(|| AndroidError::BuildToolsMissing {
239                searched: self
240                    .build_tools
241                    .iter()
242                    .map(|tools| tools.directory.clone())
243                    .chain(
244                        self.sdk_roots_searched
245                            .iter()
246                            .map(|root| root.join("build-tools")),
247                    )
248                    .collect(),
249                fix: "Install Android SDK Build Tools with `sdkmanager \"build-tools;<version>\"`, then run `cargo ferry doctor`.".to_owned(),
250            })?;
251        let ndk = self
252            .ndks
253            .iter()
254            .filter(|ndk| ndk.llvm_prebuilt.is_some())
255            .max_by(|left, right| compare_versions(&left.version, &right.version))
256            .cloned()
257            .ok_or_else(|| AndroidError::ToolMissing {
258                tool: "Android NDK LLVM toolchain".to_owned(),
259                searched: self.ndk_roots_searched.clone(),
260                fix: "Install an NDK with `sdkmanager \"ndk;<version>\"`, then run `cargo ferry doctor`.".to_owned(),
261            })?;
262        let cargo = require_host_tool(
263            "cargo",
264            self.host_tools.cargo.as_ref(),
265            &[],
266            "Install Rust through rustup, then run `cargo ferry doctor`.",
267        )?;
268        let keytool = require_host_tool(
269            "keytool",
270            self.host_tools.keytool.as_ref(),
271            &[],
272            "Install a JDK and set JAVA_HOME, then run `cargo ferry doctor`.",
273        )?;
274        Ok(AndroidToolchain {
275            sdk_root: platform.sdk_root.clone(),
276            platform,
277            build_tools,
278            ndk,
279            cargo,
280            rustc: self.host_tools.rustc.clone(),
281            rustup: self.host_tools.rustup.clone(),
282            java: self.host_tools.java.clone(),
283            javac: self.host_tools.javac.clone(),
284            keytool,
285        })
286    }
287}
288
289/// Selected tools used by one direct Android build.
290#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
291pub struct AndroidToolchain {
292    /// Android SDK root.
293    pub sdk_root: Utf8PathBuf,
294    /// Selected platform.
295    pub platform: AndroidPlatform,
296    /// Selected complete Build Tools.
297    pub build_tools: AndroidBuildTools,
298    /// Selected NDK.
299    pub ndk: AndroidNdk,
300    /// Cargo executable.
301    pub cargo: Utf8PathBuf,
302    /// Optional Rust compiler for doctor reporting.
303    pub rustc: Option<Utf8PathBuf>,
304    /// Optional rustup executable for target checks.
305    pub rustup: Option<Utf8PathBuf>,
306    /// Optional Java runtime.
307    pub java: Option<Utf8PathBuf>,
308    /// Optional Java compiler.
309    pub javac: Option<Utf8PathBuf>,
310    /// Key store utility.
311    pub keytool: Utf8PathBuf,
312}
313
314impl AndroidToolchain {
315    /// Resolve the NDK Clang driver for one ABI and minimum API level.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error when the selected NDK has no host prebuilt or matching Clang driver.
320    pub fn linker_for(&self, abi: AndroidAbi, min_sdk: u32) -> Result<Utf8PathBuf, AndroidError> {
321        self.ndk.linker_for(abi, min_sdk)
322    }
323
324    /// Resolve the NDK LLVM archiver.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error when the selected NDK lacks its host prebuilt or `llvm-ar`.
329    pub fn llvm_ar(&self) -> Result<Utf8PathBuf, AndroidError> {
330        self.ndk.llvm_ar()
331    }
332}
333
334/// Inspect configured and conventional Android SDK/NDK locations without changing the system.
335pub fn discover_android(options: &DiscoveryOptions) -> AndroidDiscovery {
336    let sdk_roots_searched = sdk_candidates(options);
337    let mut platforms = Vec::new();
338    let mut build_tools = Vec::new();
339    for root in &sdk_roots_searched {
340        platforms.extend(discover_platforms(root));
341        build_tools.extend(discover_build_tools(root));
342    }
343    platforms.sort_by_key(|platform| platform.api_level);
344    build_tools.sort_by(|left, right| compare_versions(&left.version, &right.version));
345
346    let ndk_roots_searched = ndk_candidates(options, &sdk_roots_searched);
347    let mut ndks = ndk_roots_searched
348        .iter()
349        .filter(|path| path.is_dir())
350        .map(|root| discover_ndk(root, options.host_tag.as_deref()))
351        .collect::<Vec<_>>();
352    ndks.sort_by(|left, right| compare_versions(&left.version, &right.version));
353
354    let sdk_root = sdk_roots_searched
355        .iter()
356        .find(|root| root.is_dir())
357        .cloned();
358    AndroidDiscovery {
359        sdk_roots_searched,
360        ndk_roots_searched,
361        platforms,
362        build_tools,
363        ndks,
364        host_tools: discover_host_tools(options, sdk_root.as_ref()),
365    }
366}
367
368fn sdk_candidates(options: &DiscoveryOptions) -> Vec<Utf8PathBuf> {
369    let mut paths = Vec::new();
370    if let Some(root) = &options.sdk_root {
371        paths.push(root.clone());
372    }
373    if let Some(home) = &options.home_dir {
374        #[cfg(target_os = "macos")]
375        paths.push(home.join("Library/Android/sdk"));
376        #[cfg(target_os = "linux")]
377        paths.push(home.join("Android/Sdk"));
378        #[cfg(target_os = "windows")]
379        paths.push(home.join("AppData/Local/Android/Sdk"));
380    }
381    deduplicate(paths)
382}
383
384fn ndk_candidates(options: &DiscoveryOptions, sdk_roots: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
385    let mut paths = Vec::new();
386    if let Some(root) = &options.ndk_root {
387        paths.push(root.clone());
388    }
389    for sdk in sdk_roots {
390        let versioned = sdk.join("ndk");
391        paths.extend(read_directories(&versioned));
392        paths.push(sdk.join("ndk-bundle"));
393    }
394    deduplicate(paths)
395}
396
397fn discover_platforms(sdk_root: &Utf8Path) -> Vec<AndroidPlatform> {
398    read_directories(&sdk_root.join("platforms"))
399        .into_iter()
400        .filter_map(|directory| {
401            let api_level = directory
402                .file_name()?
403                .strip_prefix("android-")?
404                .parse()
405                .ok()?;
406            let android_jar = directory.join("android.jar");
407            android_jar.is_file().then(|| AndroidPlatform {
408                sdk_root: sdk_root.to_owned(),
409                api_level,
410                directory,
411                android_jar,
412            })
413        })
414        .collect()
415}
416
417fn discover_build_tools(sdk_root: &Utf8Path) -> Vec<AndroidBuildTools> {
418    read_directories(&sdk_root.join("build-tools"))
419        .into_iter()
420        .filter_map(|directory| {
421            let version = directory.file_name()?.to_owned();
422            Some(AndroidBuildTools {
423                sdk_root: sdk_root.to_owned(),
424                version,
425                aapt2: executable_in(&directory, "aapt2"),
426                d8: script_or_executable_in(&directory, "d8"),
427                zipalign: executable_in(&directory, "zipalign"),
428                apksigner: script_or_executable_in(&directory, "apksigner"),
429                directory,
430            })
431        })
432        .collect()
433}
434
435fn discover_ndk(root: &Utf8Path, requested_host: Option<&str>) -> AndroidNdk {
436    let version = fs::read_to_string(root.join("source.properties"))
437        .ok()
438        .and_then(|source| {
439            source.lines().find_map(|line| {
440                let (key, value) = line.split_once('=')?;
441                (key.trim() == "Pkg.Revision").then(|| value.trim().to_owned())
442            })
443        })
444        .or_else(|| root.file_name().map(ToOwned::to_owned))
445        .unwrap_or_else(|| "unknown".to_owned());
446    let prebuilts = read_directories(&root.join("toolchains/llvm/prebuilt"));
447    let llvm_prebuilt = requested_host
448        .and_then(|host| {
449            prebuilts
450                .iter()
451                .find(|path| path.file_name() == Some(host))
452                .cloned()
453        })
454        .or_else(|| {
455            let prefix = host_prefix();
456            prebuilts
457                .iter()
458                .find(|path| {
459                    path.file_name()
460                        .is_some_and(|name| name.starts_with(prefix))
461                })
462                .cloned()
463        });
464    AndroidNdk {
465        root: root.to_owned(),
466        version,
467        llvm_prebuilt,
468    }
469}
470
471fn discover_host_tools(
472    options: &DiscoveryOptions,
473    sdk_root: Option<&Utf8PathBuf>,
474) -> AndroidHostTools {
475    let java_bin = options.java_home.as_ref().map(|home| home.join("bin"));
476    let find_java = |name: &str| {
477        java_bin
478            .as_ref()
479            .and_then(|directory| executable_in(directory, name))
480            .or_else(|| find_on_path(name, &options.executable_search_paths))
481    };
482    let sdkmanager = sdk_root
483        .and_then(|root| find_sdkmanager(root))
484        .or_else(|| find_on_path("sdkmanager", &options.executable_search_paths));
485    AndroidHostTools {
486        cargo: find_on_path("cargo", &options.executable_search_paths),
487        rustc: find_on_path("rustc", &options.executable_search_paths),
488        rustup: find_on_path("rustup", &options.executable_search_paths),
489        java: find_java("java"),
490        javac: find_java("javac"),
491        keytool: find_java("keytool"),
492        sdkmanager,
493        adb: sdk_root.and_then(|root| executable_in(&root.join("platform-tools"), "adb")),
494        emulator: sdk_root.and_then(|root| executable_in(&root.join("emulator"), "emulator")),
495    }
496}
497
498fn find_sdkmanager(root: &Utf8Path) -> Option<Utf8PathBuf> {
499    let command_line_tools = root.join("cmdline-tools");
500    let mut candidates = read_directories(&command_line_tools);
501    candidates.sort_by(|left, right| {
502        compare_versions(
503            left.file_name().unwrap_or_default(),
504            right.file_name().unwrap_or_default(),
505        )
506    });
507    candidates
508        .into_iter()
509        .rev()
510        .find_map(|directory| script_or_executable_in(&directory.join("bin"), "sdkmanager"))
511        .or_else(|| script_or_executable_in(&root.join("tools/bin"), "sdkmanager"))
512}
513
514fn select_platform(
515    platforms: &[AndroidPlatform],
516    requested: &str,
517) -> Result<AndroidPlatform, AndroidError> {
518    let selected = if requested == "installed" {
519        platforms.iter().max_by_key(|platform| platform.api_level)
520    } else {
521        let api = requested
522            .strip_prefix("android-")
523            .unwrap_or(requested)
524            .parse::<u32>()
525            .map_err(|_| {
526                AndroidError::InvalidRequest(format!(
527                    "android.target_sdk must be `installed`, an API number, or `android-<number>`; got `{requested}`"
528                ))
529            })?;
530        platforms.iter().find(|platform| platform.api_level == api)
531    };
532    selected.cloned().ok_or_else(|| AndroidError::PlatformMissing {
533        requested: requested.to_owned(),
534        installed: platforms.iter().map(|platform| platform.api_level).collect(),
535        fix: format!(
536            "Install it with `sdkmanager \"platforms;android-{}\"`, then run `cargo ferry doctor`.",
537            requested.strip_prefix("android-").unwrap_or(requested)
538        ),
539    })
540}
541
542fn require_host_tool(
543    name: &str,
544    value: Option<&Utf8PathBuf>,
545    searched: &[Utf8PathBuf],
546    fix: &str,
547) -> Result<Utf8PathBuf, AndroidError> {
548    value.cloned().ok_or_else(|| AndroidError::ToolMissing {
549        tool: name.to_owned(),
550        searched: searched.to_vec(),
551        fix: fix.to_owned(),
552    })
553}
554
555fn read_directories(root: &Utf8Path) -> Vec<Utf8PathBuf> {
556    let Ok(entries) = fs::read_dir(root) else {
557        return Vec::new();
558    };
559    let mut paths = entries
560        .filter_map(Result::ok)
561        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
562        .filter(|path| path.is_dir())
563        .collect::<Vec<_>>();
564    paths.sort();
565    paths
566}
567
568fn find_on_path(name: &str, paths: &[Utf8PathBuf]) -> Option<Utf8PathBuf> {
569    paths
570        .iter()
571        .find_map(|directory| script_or_executable_in(directory, name))
572}
573
574fn executable_in(directory: &Utf8Path, name: &str) -> Option<Utf8PathBuf> {
575    let candidate = directory.join(format!("{name}{}", executable_suffix()));
576    candidate.is_file().then_some(candidate)
577}
578
579fn script_or_executable_in(directory: &Utf8Path, name: &str) -> Option<Utf8PathBuf> {
580    executable_in(directory, name).or_else(|| {
581        #[cfg(target_os = "windows")]
582        {
583            let candidate = directory.join(format!("{name}.bat"));
584            candidate.is_file().then_some(candidate)
585        }
586        #[cfg(not(target_os = "windows"))]
587        {
588            let candidate = directory.join(name);
589            candidate.is_file().then_some(candidate)
590        }
591    })
592}
593
594fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
595    numeric_version(left)
596        .cmp(&numeric_version(right))
597        .then_with(|| left.cmp(right))
598}
599
600fn numeric_version(value: &str) -> Vec<u64> {
601    value
602        .split(|character: char| !character.is_ascii_digit())
603        .filter(|part| !part.is_empty())
604        .filter_map(|part| part.parse().ok())
605        .collect()
606}
607
608fn deduplicate(paths: Vec<Utf8PathBuf>) -> Vec<Utf8PathBuf> {
609    let mut seen = BTreeSet::new();
610    paths
611        .into_iter()
612        .filter(|path| seen.insert(path.clone()))
613        .collect()
614}
615
616fn env_path(name: &str) -> Option<Utf8PathBuf> {
617    env::var_os(name).and_then(|value| Utf8PathBuf::from_path_buf(PathBuf::from(value)).ok())
618}
619
620#[cfg(target_os = "windows")]
621const fn home_variable() -> &'static str {
622    "USERPROFILE"
623}
624
625#[cfg(not(target_os = "windows"))]
626const fn home_variable() -> &'static str {
627    "HOME"
628}
629
630#[cfg(target_os = "windows")]
631const fn executable_suffix() -> &'static str {
632    ".exe"
633}
634
635#[cfg(not(target_os = "windows"))]
636const fn executable_suffix() -> &'static str {
637    ""
638}
639
640#[cfg(target_os = "windows")]
641const fn ndk_clang_suffix() -> &'static str {
642    ".cmd"
643}
644
645#[cfg(not(target_os = "windows"))]
646const fn ndk_clang_suffix() -> &'static str {
647    ""
648}
649
650#[cfg(target_os = "macos")]
651const fn host_prefix() -> &'static str {
652    "darwin-"
653}
654
655#[cfg(target_os = "linux")]
656const fn host_prefix() -> &'static str {
657    "linux-"
658}
659
660#[cfg(target_os = "windows")]
661const fn host_prefix() -> &'static str {
662    "windows-"
663}
664
665#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
666const fn host_prefix() -> &'static str {
667    ""
668}
669
670#[cfg(test)]
671mod tests {
672    use std::fs;
673
674    use tempfile::TempDir;
675
676    use super::*;
677
678    fn touch(path: &Utf8Path) {
679        fs::create_dir_all(path.parent().unwrap()).unwrap();
680        fs::write(path, b"").unwrap();
681    }
682
683    #[test]
684    fn finds_latest_complete_sdk_and_ndk() {
685        let temp = TempDir::new().unwrap();
686        let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
687        for api in [34, 35] {
688            touch(&root.join(format!("platforms/android-{api}/android.jar")));
689        }
690        for version in ["34.0.0", "35.0.0"] {
691            let directory = root.join("build-tools").join(version);
692            for tool in ["aapt2", "d8", "zipalign", "apksigner"] {
693                touch(&directory.join(format!("{tool}{}", executable_suffix())));
694            }
695        }
696        let ndk = root.join("ndk/27.1.0");
697        touch(&ndk.join(format!(
698            "toolchains/llvm/prebuilt/test-host/bin/llvm-ar{}",
699            executable_suffix()
700        )));
701        touch(&ndk.join(format!(
702            "toolchains/llvm/prebuilt/test-host/bin/aarch64-linux-android26-clang{}",
703            ndk_clang_suffix()
704        )));
705        fs::write(ndk.join("source.properties"), "Pkg.Revision = 27.1.0\n").unwrap();
706        let bin = root.join("host-bin");
707        for tool in ["cargo", "rustc", "rustup", "java", "javac", "keytool"] {
708            touch(&bin.join(format!("{tool}{}", executable_suffix())));
709        }
710        let options = DiscoveryOptions {
711            sdk_root: Some(root.clone()),
712            ndk_root: Some(ndk),
713            java_home: None,
714            executable_search_paths: vec![bin],
715            home_dir: None,
716            host_tag: Some("test-host".to_owned()),
717        };
718        let discovery = discover_android(&options);
719        let toolchain = discovery
720            .select_toolchain(&AndroidConfig::default())
721            .unwrap();
722        assert_eq!(toolchain.platform.api_level, 35);
723        assert_eq!(toolchain.build_tools.version, "35.0.0");
724        let expected_linker = format!("aarch64-linux-android26-clang{}", ndk_clang_suffix());
725        assert_eq!(
726            toolchain
727                .linker_for(AndroidAbi::Arm64V8a, 26)
728                .unwrap()
729                .file_name(),
730            Some(expected_linker.as_str())
731        );
732    }
733
734    #[test]
735    fn incomplete_newer_build_tools_are_skipped() {
736        let mut discovery = AndroidDiscovery {
737            sdk_roots_searched: vec![Utf8PathBuf::from("/sdk")],
738            ndk_roots_searched: vec![],
739            platforms: vec![AndroidPlatform {
740                sdk_root: "/sdk".into(),
741                api_level: 35,
742                directory: "/sdk/platforms/android-35".into(),
743                android_jar: "/sdk/platforms/android-35/android.jar".into(),
744            }],
745            build_tools: vec![],
746            ndks: vec![],
747            host_tools: AndroidHostTools::default(),
748        };
749        let complete = AndroidBuildTools {
750            sdk_root: "/sdk".into(),
751            version: "34.0.0".into(),
752            directory: "/sdk/build-tools/34.0.0".into(),
753            aapt2: Some("/aapt2".into()),
754            d8: Some("/d8".into()),
755            zipalign: Some("/zipalign".into()),
756            apksigner: Some("/apksigner".into()),
757        };
758        let mut incomplete = complete.clone();
759        incomplete.version = "35.0.0".into();
760        incomplete.d8 = None;
761        discovery.build_tools = vec![complete, incomplete];
762        assert!(discovery.build_tools[0].is_complete());
763        assert!(!discovery.build_tools[1].is_complete());
764    }
765}