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#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct DiscoveryOptions {
12 pub sdk_root: Option<Utf8PathBuf>,
14 pub ndk_root: Option<Utf8PathBuf>,
16 pub java_home: Option<Utf8PathBuf>,
18 pub executable_search_paths: Vec<Utf8PathBuf>,
20 pub home_dir: Option<Utf8PathBuf>,
22 pub host_tag: Option<String>,
24}
25
26impl DiscoveryOptions {
27 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
58pub struct AndroidPlatform {
59 pub sdk_root: Utf8PathBuf,
61 pub api_level: u32,
63 pub directory: Utf8PathBuf,
65 pub android_jar: Utf8PathBuf,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct AndroidBuildTools {
72 pub sdk_root: Utf8PathBuf,
74 pub version: String,
76 pub directory: Utf8PathBuf,
78 pub aapt2: Option<Utf8PathBuf>,
80 pub d8: Option<Utf8PathBuf>,
82 pub zipalign: Option<Utf8PathBuf>,
84 pub apksigner: Option<Utf8PathBuf>,
86}
87
88impl AndroidBuildTools {
89 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
100pub struct AndroidNdk {
101 pub root: Utf8PathBuf,
103 pub version: String,
105 pub llvm_prebuilt: Option<Utf8PathBuf>,
107}
108
109impl AndroidNdk {
110 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 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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
177pub struct AndroidHostTools {
178 pub cargo: Option<Utf8PathBuf>,
180 pub rustc: Option<Utf8PathBuf>,
182 pub rustup: Option<Utf8PathBuf>,
184 pub java: Option<Utf8PathBuf>,
186 pub javac: Option<Utf8PathBuf>,
188 pub keytool: Option<Utf8PathBuf>,
190 pub sdkmanager: Option<Utf8PathBuf>,
192 pub adb: Option<Utf8PathBuf>,
194 pub emulator: Option<Utf8PathBuf>,
196}
197
198#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
200pub struct AndroidDiscovery {
201 pub sdk_roots_searched: Vec<Utf8PathBuf>,
203 pub ndk_roots_searched: Vec<Utf8PathBuf>,
205 pub platforms: Vec<AndroidPlatform>,
207 pub build_tools: Vec<AndroidBuildTools>,
209 pub ndks: Vec<AndroidNdk>,
211 pub host_tools: AndroidHostTools,
213}
214
215impl AndroidDiscovery {
216 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
291pub struct AndroidToolchain {
292 pub sdk_root: Utf8PathBuf,
294 pub platform: AndroidPlatform,
296 pub build_tools: AndroidBuildTools,
298 pub ndk: AndroidNdk,
300 pub cargo: Utf8PathBuf,
302 pub rustc: Option<Utf8PathBuf>,
304 pub rustup: Option<Utf8PathBuf>,
306 pub java: Option<Utf8PathBuf>,
308 pub javac: Option<Utf8PathBuf>,
310 pub keytool: Utf8PathBuf,
312}
313
314impl AndroidToolchain {
315 pub fn linker_for(&self, abi: AndroidAbi, min_sdk: u32) -> Result<Utf8PathBuf, AndroidError> {
321 self.ndk.linker_for(abi, min_sdk)
322 }
323
324 pub fn llvm_ar(&self) -> Result<Utf8PathBuf, AndroidError> {
330 self.ndk.llvm_ar()
331 }
332}
333
334pub 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}