mobench_sdk/builders/
android.rs

1//! Android build automation.
2//!
3//! This module provides [`AndroidBuilder`] which handles the complete pipeline for
4//! building Rust libraries for Android and packaging them into an APK using Gradle.
5//!
6//! ## Build Pipeline
7//!
8//! The builder performs these steps:
9//!
10//! 1. **Project scaffolding** - Auto-generates Android project if missing
11//! 2. **Rust compilation** - Builds native `.so` libraries for Android ABIs using `cargo-ndk`
12//! 3. **Binding generation** - Generates UniFFI Kotlin bindings
13//! 4. **Library packaging** - Copies `.so` files to `jniLibs/` directories
14//! 5. **APK building** - Runs Gradle to build the app APK
15//! 6. **Test APK building** - Builds the androidTest APK for BrowserStack Espresso
16//!
17//! ## Requirements
18//!
19//! - Android NDK (set `ANDROID_NDK_HOME` environment variable)
20//! - `cargo-ndk` (`cargo install cargo-ndk`)
21//! - Rust targets: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android`
22//! - Java JDK (for Gradle)
23//!
24//! ## Example
25//!
26//! ```ignore
27//! use mobench_sdk::builders::AndroidBuilder;
28//! use mobench_sdk::{BuildConfig, BuildProfile, Target};
29//!
30//! let builder = AndroidBuilder::new(".", "my-bench-crate")
31//!     .verbose(true)
32//!     .dry_run(false);  // Set to true to preview without building
33//!
34//! let config = BuildConfig {
35//!     target: Target::Android,
36//!     profile: BuildProfile::Release,
37//!     incremental: true,
38//! };
39//!
40//! let result = builder.build(&config)?;
41//! println!("APK at: {:?}", result.app_path);
42//! println!("Test APK at: {:?}", result.test_suite_path);
43//! # Ok::<(), mobench_sdk::BenchError>(())
44//! ```
45//!
46//! ## Dry-Run Mode
47//!
48//! Use `dry_run(true)` to preview the build plan without making changes:
49//!
50//! ```ignore
51//! let builder = AndroidBuilder::new(".", "my-bench")
52//!     .dry_run(true);
53//!
54//! // This will print the build plan but not execute anything
55//! builder.build(&config)?;
56//! ```
57
58use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
59use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
60use std::env;
61use std::fs;
62use std::path::{Path, PathBuf};
63use std::process::Command;
64
65/// Android builder that handles the complete build pipeline.
66///
67/// This builder automates the process of compiling Rust code to Android native
68/// libraries, generating UniFFI Kotlin bindings, and packaging everything into
69/// an APK ready for deployment.
70///
71/// # Example
72///
73/// ```ignore
74/// use mobench_sdk::builders::AndroidBuilder;
75/// use mobench_sdk::{BuildConfig, BuildProfile, Target};
76///
77/// let builder = AndroidBuilder::new(".", "my-bench")
78///     .verbose(true)
79///     .output_dir("target/mobench");
80///
81/// let config = BuildConfig {
82///     target: Target::Android,
83///     profile: BuildProfile::Release,
84///     incremental: true,
85/// };
86///
87/// let result = builder.build(&config)?;
88/// # Ok::<(), mobench_sdk::BenchError>(())
89/// ```
90pub struct AndroidBuilder {
91    /// Root directory of the project
92    project_root: PathBuf,
93    /// Output directory for mobile artifacts (defaults to target/mobench)
94    output_dir: PathBuf,
95    /// Name of the bench-mobile crate
96    crate_name: String,
97    /// Whether to use verbose output
98    verbose: bool,
99    /// Optional explicit crate directory (overrides auto-detection)
100    crate_dir: Option<PathBuf>,
101    /// Whether to run in dry-run mode (print what would be done without making changes)
102    dry_run: bool,
103}
104
105impl AndroidBuilder {
106    /// Creates a new Android builder
107    ///
108    /// # Arguments
109    ///
110    /// * `project_root` - Root directory containing the bench-mobile crate
111    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
112    pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
113        let root = project_root.into();
114        Self {
115            output_dir: root.join("target/mobench"),
116            project_root: root,
117            crate_name: crate_name.into(),
118            verbose: false,
119            crate_dir: None,
120            dry_run: false,
121        }
122    }
123
124    /// Sets the output directory for mobile artifacts
125    ///
126    /// By default, artifacts are written to `{project_root}/target/mobench/`.
127    /// Use this to customize the output location.
128    pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
129        self.output_dir = dir.into();
130        self
131    }
132
133    /// Sets the explicit crate directory
134    ///
135    /// By default, the builder searches for the crate in this order:
136    /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name`
137    /// 2. `{project_root}/bench-mobile/` - SDK-generated projects
138    /// 3. `{project_root}/crates/{crate_name}/` - workspace structure
139    /// 4. `{project_root}/{crate_name}/` - simple nested structure
140    ///
141    /// Use this to override auto-detection and point directly to the crate.
142    pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
143        self.crate_dir = Some(dir.into());
144        self
145    }
146
147    /// Enables verbose output
148    pub fn verbose(mut self, verbose: bool) -> Self {
149        self.verbose = verbose;
150        self
151    }
152
153    /// Enables dry-run mode
154    ///
155    /// In dry-run mode, the builder prints what would be done without actually
156    /// making any changes. Useful for previewing the build process.
157    pub fn dry_run(mut self, dry_run: bool) -> Self {
158        self.dry_run = dry_run;
159        self
160    }
161
162    /// Builds the Android app with the given configuration
163    ///
164    /// This performs the following steps:
165    /// 0. Auto-generate project scaffolding if missing
166    /// 1. Build Rust libraries for Android ABIs using cargo-ndk
167    /// 2. Generate UniFFI Kotlin bindings
168    /// 3. Copy .so files to jniLibs directories
169    /// 4. Run Gradle to build the APK
170    ///
171    /// # Returns
172    ///
173    /// * `Ok(BuildResult)` containing the path to the built APK
174    /// * `Err(BenchError)` if the build fails
175    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
176        // Validate project root before starting build
177        if self.crate_dir.is_none() {
178            validate_project_root(&self.project_root, &self.crate_name)?;
179        }
180
181        let android_dir = self.output_dir.join("android");
182        let profile_name = match config.profile {
183            BuildProfile::Debug => "debug",
184            BuildProfile::Release => "release",
185        };
186
187        if self.dry_run {
188            println!("\n[dry-run] Android build plan:");
189            println!("  Step 0: Check/generate Android project scaffolding at {:?}", android_dir);
190            println!("  Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)");
191            println!("  Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)");
192            println!("    Command: cargo ndk --target <abi> --platform 24 build {}",
193                if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" });
194            println!("  Step 2: Generate UniFFI Kotlin bindings");
195            println!("    Output: {:?}", android_dir.join("app/src/main/java/uniffi"));
196            println!("  Step 3: Copy .so files to jniLibs directories");
197            println!("    Destination: {:?}", android_dir.join("app/src/main/jniLibs"));
198            println!("  Step 4: Build Android APK with Gradle");
199            println!("    Command: ./gradlew assemble{}", if profile_name == "release" { "Release" } else { "Debug" });
200            println!("    Output: {:?}", android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name)));
201            println!("  Step 5: Build Android test APK");
202            println!("    Command: ./gradlew assemble{}AndroidTest", if profile_name == "release" { "Release" } else { "Debug" });
203
204            // Return a placeholder result for dry-run
205            return Ok(BuildResult {
206                platform: Target::Android,
207                app_path: android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name)),
208                test_suite_path: Some(android_dir.join(format!("app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", profile_name, profile_name))),
209            });
210        }
211
212        // Step 0: Ensure Android project scaffolding exists
213        // Pass project_root and crate_dir for better benchmark function detection
214        crate::codegen::ensure_android_project_with_options(
215            &self.output_dir,
216            &self.crate_name,
217            Some(&self.project_root),
218            self.crate_dir.as_deref(),
219        )?;
220
221        // Step 0.5: Ensure Gradle wrapper exists
222        self.ensure_gradle_wrapper(&android_dir)?;
223
224        // Step 1: Build Rust libraries
225        println!("Building Rust libraries for Android...");
226        self.build_rust_libraries(config)?;
227
228        // Step 2: Generate UniFFI bindings
229        println!("Generating UniFFI Kotlin bindings...");
230        self.generate_uniffi_bindings()?;
231
232        // Step 3: Copy .so files to jniLibs
233        println!("Copying native libraries to jniLibs...");
234        self.copy_native_libraries(config)?;
235
236        // Step 4: Build APK with Gradle
237        println!("Building Android APK with Gradle...");
238        let apk_path = self.build_apk(config)?;
239
240        // Step 5: Build Android test APK for BrowserStack
241        println!("Building Android test APK...");
242        let test_suite_path = self.build_test_apk(config)?;
243
244        // Step 6: Validate all expected artifacts exist
245        let result = BuildResult {
246            platform: Target::Android,
247            app_path: apk_path,
248            test_suite_path: Some(test_suite_path),
249        };
250        self.validate_build_artifacts(&result, config)?;
251
252        Ok(result)
253    }
254
255    /// Validates that all expected build artifacts exist after a successful build
256    fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> {
257        let mut missing = Vec::new();
258        let profile_dir = match config.profile {
259            BuildProfile::Debug => "debug",
260            BuildProfile::Release => "release",
261        };
262
263        // Check main APK
264        if !result.app_path.exists() {
265            missing.push(format!("Main APK: {}", result.app_path.display()));
266        }
267
268        // Check test APK
269        if let Some(ref test_path) = result.test_suite_path {
270            if !test_path.exists() {
271                missing.push(format!("Test APK: {}", test_path.display()));
272            }
273        }
274
275        // Check that at least one native library exists in jniLibs
276        let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
277        let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
278        let required_abis = ["arm64-v8a", "armeabi-v7a", "x86_64"];
279        let mut found_libs = 0;
280        for abi in &required_abis {
281            let lib_path = jni_libs_dir.join(abi).join(&lib_name);
282            if lib_path.exists() {
283                found_libs += 1;
284            } else {
285                missing.push(format!("Native library ({} {}): {}", abi, profile_dir, lib_path.display()));
286            }
287        }
288
289        if found_libs == 0 {
290            return Err(BenchError::Build(format!(
291                "Build validation failed: No native libraries found.\n\n\
292                 Expected at least one .so file in jniLibs directories.\n\
293                 Missing artifacts:\n{}\n\n\
294                 This usually means the Rust build step failed. Check the cargo-ndk output above.",
295                missing.iter().map(|s| format!("  - {}", s)).collect::<Vec<_>>().join("\n")
296            )));
297        }
298
299        if !missing.is_empty() {
300            eprintln!(
301                "Warning: Some build artifacts are missing:\n{}\n\
302                 The build may still work but some features might be unavailable.",
303                missing.iter().map(|s| format!("  - {}", s)).collect::<Vec<_>>().join("\n")
304            );
305        }
306
307        Ok(())
308    }
309
310    /// Finds the benchmark crate directory.
311    ///
312    /// Search order:
313    /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method
314    /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name
315    /// 3. `{project_root}/bench-mobile/` (SDK projects)
316    /// 4. `{project_root}/crates/{crate_name}/` (repository structure)
317    fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
318        // If explicit crate_dir was provided, use it
319        if let Some(ref dir) = self.crate_dir {
320            if dir.exists() {
321                return Ok(dir.clone());
322            }
323            return Err(BenchError::Build(format!(
324                "Specified crate path does not exist: {:?}.\n\n\
325                 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
326                dir
327            )));
328        }
329
330        // Check if the current directory (project_root) IS the crate
331        // This handles the case where user runs `cargo mobench build` from within the crate directory
332        let root_cargo_toml = self.project_root.join("Cargo.toml");
333        if root_cargo_toml.exists() {
334            if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) {
335                if pkg_name == self.crate_name {
336                    return Ok(self.project_root.clone());
337                }
338            }
339        }
340
341        // Try bench-mobile/ (SDK projects)
342        let bench_mobile_dir = self.project_root.join("bench-mobile");
343        if bench_mobile_dir.exists() {
344            return Ok(bench_mobile_dir);
345        }
346
347        // Try crates/{crate_name}/ (repository structure)
348        let crates_dir = self.project_root.join("crates").join(&self.crate_name);
349        if crates_dir.exists() {
350            return Ok(crates_dir);
351        }
352
353        // Also try {crate_name}/ in project root (common pattern)
354        let named_dir = self.project_root.join(&self.crate_name);
355        if named_dir.exists() {
356            return Ok(named_dir);
357        }
358
359        let root_manifest = root_cargo_toml;
360        let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
361        let crates_manifest = crates_dir.join("Cargo.toml");
362        let named_manifest = named_dir.join("Cargo.toml");
363        Err(BenchError::Build(format!(
364            "Benchmark crate '{}' not found.\n\n\
365             Searched locations:\n\
366             - {} (checked [package] name)\n\
367             - {}\n\
368             - {}\n\
369             - {}\n\n\
370             To fix this:\n\
371             1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
372             2. Create a bench-mobile/ directory with your benchmark crate, or\n\
373             3. Use --crate-path to specify the benchmark crate location:\n\
374                cargo mobench build --target android --crate-path ./my-benchmarks\n\n\
375             Common issues:\n\
376             - Typo in crate name (check Cargo.toml [package] name)\n\
377             - Wrong working directory (run from project root)\n\
378             - Missing Cargo.toml in the crate directory\n\n\
379             Run 'cargo mobench init --help' to generate a new benchmark project.",
380            self.crate_name,
381            root_manifest.display(),
382            bench_mobile_manifest.display(),
383            crates_manifest.display(),
384            named_manifest.display(),
385            self.crate_name,
386        )))
387    }
388
389    /// Builds Rust libraries for Android using cargo-ndk
390    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
391        let crate_dir = self.find_crate_dir()?;
392
393        // Check if cargo-ndk is installed
394        self.check_cargo_ndk()?;
395
396        // Android ABIs to build for
397        let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"];
398        let release_flag = if matches!(config.profile, BuildProfile::Release) {
399            "--release"
400        } else {
401            ""
402        };
403
404        for abi in abis {
405            if self.verbose {
406                println!("  Building for {}", abi);
407            }
408
409            let mut cmd = Command::new("cargo");
410            cmd.arg("ndk")
411                .arg("--target")
412                .arg(abi)
413                .arg("--platform")
414                .arg("24") // minSdk
415                .arg("build");
416
417            // Add release flag if needed
418            if !release_flag.is_empty() {
419                cmd.arg(release_flag);
420            }
421
422            // Set working directory
423            cmd.current_dir(&crate_dir);
424
425            // Execute build
426            let command_hint = if release_flag.is_empty() {
427                format!("cargo ndk --target {} --platform 24 build", abi)
428            } else {
429                format!("cargo ndk --target {} --platform 24 build {}", abi, release_flag)
430            };
431            let output = cmd
432                .output()
433                .map_err(|e| BenchError::Build(format!(
434                    "Failed to start cargo-ndk for {}.\n\n\
435                     Command: {}\n\
436                     Crate directory: {}\n\
437                     System error: {}\n\n\
438                     Tips:\n\
439                     - Install cargo-ndk: cargo install cargo-ndk\n\
440                     - Ensure cargo is on PATH",
441                    abi,
442                    command_hint,
443                    crate_dir.display(),
444                    e
445                )))?;
446
447            if !output.status.success() {
448                let stdout = String::from_utf8_lossy(&output.stdout);
449                let stderr = String::from_utf8_lossy(&output.stderr);
450                let profile = if matches!(config.profile, BuildProfile::Release) {
451                    "release"
452                } else {
453                    "debug"
454                };
455                let rust_target = match abi {
456                    "arm64-v8a" => "aarch64-linux-android",
457                    "armeabi-v7a" => "armv7-linux-androideabi",
458                    "x86_64" => "x86_64-linux-android",
459                    _ => abi,
460                };
461                return Err(BenchError::Build(format!(
462                    "cargo-ndk build failed for {} ({} profile).\n\n\
463                     Command: {}\n\
464                     Crate directory: {}\n\
465                     Exit status: {}\n\n\
466                     Stdout:\n{}\n\n\
467                     Stderr:\n{}\n\n\
468                     Common causes:\n\
469                     - Missing Rust target: rustup target add {}\n\
470                     - NDK not found: set ANDROID_NDK_HOME\n\
471                     - Compilation error in Rust code (see output above)\n\
472                     - Incompatible native dependencies (some C libraries do not support Android)",
473                    abi,
474                    profile,
475                    command_hint,
476                    crate_dir.display(),
477                    output.status,
478                    stdout,
479                    stderr,
480                    rust_target,
481                )));
482            }
483        }
484
485        Ok(())
486    }
487
488    /// Checks if cargo-ndk is installed
489    fn check_cargo_ndk(&self) -> Result<(), BenchError> {
490        let output = Command::new("cargo").arg("ndk").arg("--version").output();
491
492        match output {
493            Ok(output) if output.status.success() => Ok(()),
494            _ => Err(BenchError::Build(
495                "cargo-ndk is not installed or not in PATH.\n\n\
496                 cargo-ndk is required to cross-compile Rust for Android.\n\n\
497                 To install:\n\
498                   cargo install cargo-ndk\n\
499                 Verify with:\n\
500                   cargo ndk --version\n\n\
501                 You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\
502                 See: https://github.com/nickelc/cargo-ndk"
503                    .to_string(),
504            )),
505        }
506    }
507
508    /// Generates UniFFI Kotlin bindings
509    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
510        let crate_dir = self.find_crate_dir()?;
511        let crate_name_underscored = self.crate_name.replace("-", "_");
512
513        // Check if bindings already exist (for repository testing with pre-generated bindings)
514        let bindings_path = self
515            .output_dir
516            .join("android")
517            .join("app")
518            .join("src")
519            .join("main")
520            .join("java")
521            .join("uniffi")
522            .join(&crate_name_underscored)
523            .join(format!("{}.kt", crate_name_underscored));
524
525        if bindings_path.exists() {
526            if self.verbose {
527                println!("  Using existing Kotlin bindings at {:?}", bindings_path);
528            }
529            return Ok(());
530        }
531
532        // Build host library to feed uniffi-bindgen
533        let mut build_cmd = Command::new("cargo");
534        build_cmd.arg("build");
535        build_cmd.current_dir(&crate_dir);
536        run_command(build_cmd, "cargo build (host)")?;
537
538        let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
539        let out_dir = self
540            .output_dir
541            .join("android")
542            .join("app")
543            .join("src")
544            .join("main")
545            .join("java");
546
547        // Try cargo run first (works if crate has uniffi-bindgen binary target)
548        let cargo_run_result = Command::new("cargo")
549            .args(["run", "-p", &self.crate_name, "--bin", "uniffi-bindgen", "--"])
550            .arg("generate")
551            .arg("--library")
552            .arg(&lib_path)
553            .arg("--language")
554            .arg("kotlin")
555            .arg("--out-dir")
556            .arg(&out_dir)
557            .current_dir(&crate_dir)
558            .output();
559
560        let use_cargo_run = cargo_run_result
561            .as_ref()
562            .map(|o| o.status.success())
563            .unwrap_or(false);
564
565        if use_cargo_run {
566            if self.verbose {
567                println!("  Generated bindings using cargo run uniffi-bindgen");
568            }
569        } else {
570            // Fall back to global uniffi-bindgen
571            let uniffi_available = Command::new("uniffi-bindgen")
572                .arg("--version")
573                .output()
574                .map(|o| o.status.success())
575                .unwrap_or(false);
576
577            if !uniffi_available {
578                return Err(BenchError::Build(
579                    "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
580                     To fix this, either:\n\
581                     1. Add a uniffi-bindgen binary to your crate:\n\
582                        [[bin]]\n\
583                        name = \"uniffi-bindgen\"\n\
584                        path = \"src/bin/uniffi-bindgen.rs\"\n\n\
585                     2. Or install uniffi-bindgen globally:\n\
586                        cargo install uniffi-bindgen\n\n\
587                     3. Or pre-generate bindings and commit them."
588                        .to_string(),
589                ));
590            }
591
592            let mut cmd = Command::new("uniffi-bindgen");
593            cmd.arg("generate")
594                .arg("--library")
595                .arg(&lib_path)
596                .arg("--language")
597                .arg("kotlin")
598                .arg("--out-dir")
599                .arg(&out_dir);
600            run_command(cmd, "uniffi-bindgen kotlin")?;
601        }
602
603        if self.verbose {
604            println!("  Generated UniFFI Kotlin bindings at {:?}", out_dir);
605        }
606        Ok(())
607    }
608
609    /// Copies .so files to Android jniLibs directories
610    fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
611        let crate_dir = self.find_crate_dir()?;
612        let profile_dir = match config.profile {
613            BuildProfile::Debug => "debug",
614            BuildProfile::Release => "release",
615        };
616
617        // Use cargo metadata to find the actual target directory (handles workspaces)
618        let target_dir = get_cargo_target_dir(&crate_dir)?;
619        let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
620
621        // Create jniLibs directories if they don't exist
622        std::fs::create_dir_all(&jni_libs_dir).map_err(|e| {
623            BenchError::Build(format!(
624                "Failed to create jniLibs directory at {}: {}. Check output directory permissions.",
625                jni_libs_dir.display(),
626                e
627            ))
628        })?;
629
630        // Map cargo-ndk ABIs to Android jniLibs ABIs
631        let abi_mappings = vec![
632            ("aarch64-linux-android", "arm64-v8a"),
633            ("armv7-linux-androideabi", "armeabi-v7a"),
634            ("x86_64-linux-android", "x86_64"),
635        ];
636
637        for (rust_target, android_abi) in abi_mappings {
638            let src = target_dir
639                .join(rust_target)
640                .join(profile_dir)
641                .join(format!("lib{}.so", self.crate_name.replace("-", "_")));
642
643            let dest_dir = jni_libs_dir.join(android_abi);
644            std::fs::create_dir_all(&dest_dir).map_err(|e| {
645                BenchError::Build(format!(
646                    "Failed to create ABI directory {} at {}: {}. Check output directory permissions.",
647                    android_abi,
648                    dest_dir.display(),
649                    e
650                ))
651            })?;
652
653            let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_")));
654
655            if src.exists() {
656                std::fs::copy(&src, &dest).map_err(|e| {
657                    BenchError::Build(format!(
658                        "Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.",
659                        android_abi,
660                        src.display(),
661                        dest.display(),
662                        e
663                    ))
664                })?;
665
666                if self.verbose {
667                    println!("  Copied {} -> {}", src.display(), dest.display());
668                }
669            } else {
670                // Always warn about missing native libraries - this will cause runtime crashes
671                eprintln!(
672                    "Warning: Native library for {} not found at {}.\n\
673                     This will cause a runtime crash when the app tries to load the library.\n\
674                     Ensure cargo-ndk build completed successfully for this ABI.",
675                    android_abi,
676                    src.display()
677                );
678            }
679        }
680
681        Ok(())
682    }
683
684    /// Ensures local.properties exists with sdk.dir set
685    ///
686    /// Gradle requires this file to know where the Android SDK is located.
687    /// This function only generates the file if ANDROID_HOME or ANDROID_SDK_ROOT
688    /// environment variables are set. We intentionally avoid probing filesystem
689    /// paths to prevent writing machine-specific paths that would break builds
690    /// on other machines.
691    ///
692    /// If neither environment variable is set, we skip generating the file and
693    /// let Android Studio or Gradle handle SDK detection.
694    fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> {
695        let local_props = android_dir.join("local.properties");
696
697        // If local.properties already exists, leave it alone
698        if local_props.exists() {
699            return Ok(());
700        }
701
702        // Only generate local.properties if an environment variable is set.
703        // This avoids writing machine-specific paths that break on other machines.
704        let sdk_dir = self.find_android_sdk_from_env();
705
706        match sdk_dir {
707            Some(path) => {
708                // Write local.properties with the SDK path from env var
709                let content = format!("sdk.dir={}\n", path.display());
710                fs::write(&local_props, content).map_err(|e| {
711                    BenchError::Build(format!(
712                        "Failed to write local.properties at {:?}: {}. Check output directory permissions.",
713                        local_props, e
714                    ))
715                })?;
716
717                if self.verbose {
718                    println!("  Generated local.properties with sdk.dir={}", path.display());
719                }
720            }
721            None => {
722                // No env var set - skip generating local.properties
723                // Gradle/Android Studio will auto-detect the SDK or prompt the user
724                if self.verbose {
725                    println!("  Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)");
726                    println!("  Gradle will auto-detect SDK or you can create local.properties manually");
727                }
728            }
729        }
730
731        Ok(())
732    }
733
734    /// Finds the Android SDK installation path from environment variables only
735    ///
736    /// Returns Some(path) if ANDROID_HOME or ANDROID_SDK_ROOT is set and the path exists.
737    /// Returns None if neither is set or the paths don't exist.
738    ///
739    /// We intentionally avoid probing common filesystem locations to prevent
740    /// writing machine-specific paths that would break builds on other machines.
741    fn find_android_sdk_from_env(&self) -> Option<PathBuf> {
742        // Check ANDROID_HOME first (standard)
743        if let Ok(path) = env::var("ANDROID_HOME") {
744            let sdk_path = PathBuf::from(&path);
745            if sdk_path.exists() {
746                return Some(sdk_path);
747            }
748        }
749
750        // Check ANDROID_SDK_ROOT (alternative)
751        if let Ok(path) = env::var("ANDROID_SDK_ROOT") {
752            let sdk_path = PathBuf::from(&path);
753            if sdk_path.exists() {
754                return Some(sdk_path);
755            }
756        }
757
758        None
759    }
760
761    /// Ensures the Gradle wrapper (gradlew) exists in the Android project
762    ///
763    /// If gradlew doesn't exist, this runs `gradle wrapper --gradle-version 8.5`
764    /// to generate the wrapper files.
765    fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> {
766        let gradlew = android_dir.join("gradlew");
767
768        // If gradlew already exists, we're good
769        if gradlew.exists() {
770            return Ok(());
771        }
772
773        println!("Gradle wrapper not found, generating...");
774
775        // Check if gradle is available
776        let gradle_available = Command::new("gradle")
777            .arg("--version")
778            .output()
779            .map(|o| o.status.success())
780            .unwrap_or(false);
781
782        if !gradle_available {
783            return Err(BenchError::Build(
784                "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\
785                 The Android project requires Gradle to build. You have two options:\n\n\
786                 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\
787                    - macOS: brew install gradle\n\
788                    - Linux: sudo apt install gradle\n\
789                    - Or download from https://gradle.org/install/\n\n\
790                 2. Or generate the wrapper manually in the Android project directory:\n\
791                    cd target/mobench/android && gradle wrapper --gradle-version 8.5"
792                    .to_string(),
793            ));
794        }
795
796        // Run gradle wrapper to generate gradlew
797        let mut cmd = Command::new("gradle");
798        cmd.arg("wrapper")
799            .arg("--gradle-version")
800            .arg("8.5")
801            .current_dir(android_dir);
802
803        let output = cmd.output().map_err(|e| {
804            BenchError::Build(format!(
805                "Failed to run 'gradle wrapper' command: {}\n\n\
806                 Ensure Gradle is installed and on your PATH.",
807                e
808            ))
809        })?;
810
811        if !output.status.success() {
812            let stderr = String::from_utf8_lossy(&output.stderr);
813            return Err(BenchError::Build(format!(
814                "Failed to generate Gradle wrapper.\n\n\
815                 Command: gradle wrapper --gradle-version 8.5\n\
816                 Working directory: {}\n\
817                 Exit status: {}\n\
818                 Stderr: {}\n\n\
819                 Try running this command manually in the Android project directory.",
820                android_dir.display(),
821                output.status,
822                stderr
823            )));
824        }
825
826        // Make gradlew executable on Unix systems
827        #[cfg(unix)]
828        {
829            use std::os::unix::fs::PermissionsExt;
830            if let Ok(metadata) = fs::metadata(&gradlew) {
831                let mut perms = metadata.permissions();
832                perms.set_mode(0o755);
833                let _ = fs::set_permissions(&gradlew, perms);
834            }
835        }
836
837        if self.verbose {
838            println!("  Generated Gradle wrapper at {:?}", gradlew);
839        }
840
841        Ok(())
842    }
843
844    /// Builds the Android APK using Gradle
845    fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
846        let android_dir = self.output_dir.join("android");
847
848        if !android_dir.exists() {
849            return Err(BenchError::Build(format!(
850                "Android project not found at {}.\n\n\
851                 Expected a Gradle project under the output directory.\n\
852                 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
853                android_dir.display()
854            )));
855        }
856
857        // Ensure local.properties exists with sdk.dir
858        self.ensure_local_properties(&android_dir)?;
859
860        // Determine Gradle task
861        let gradle_task = match config.profile {
862            BuildProfile::Debug => "assembleDebug",
863            BuildProfile::Release => "assembleRelease",
864        };
865
866        // Run Gradle build
867        let mut cmd = Command::new("./gradlew");
868        cmd.arg(gradle_task).current_dir(&android_dir);
869
870        if self.verbose {
871            cmd.arg("--info");
872        }
873
874        let output = cmd
875            .output()
876            .map_err(|e| BenchError::Build(format!(
877                "Failed to run Gradle wrapper.\n\n\
878                 Command: ./gradlew {}\n\
879                 Working directory: {}\n\
880                 Error: {}\n\n\
881                 Tips:\n\
882                 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
883                 - Run ./gradlew --version in that directory to verify the wrapper",
884                gradle_task,
885                android_dir.display(),
886                e
887            )))?;
888
889        if !output.status.success() {
890            let stdout = String::from_utf8_lossy(&output.stdout);
891            let stderr = String::from_utf8_lossy(&output.stderr);
892            return Err(BenchError::Build(format!(
893                "Gradle build failed.\n\n\
894                 Command: ./gradlew {}\n\
895                 Working directory: {}\n\
896                 Exit status: {}\n\n\
897                 Stdout:\n{}\n\n\
898                 Stderr:\n{}\n\n\
899                 Tips:\n\
900                 - Re-run with verbose mode to pass --info to Gradle\n\
901                 - Run ./gradlew {} --stacktrace for a full stack trace",
902                gradle_task,
903                android_dir.display(),
904                output.status,
905                stdout,
906                stderr,
907                gradle_task,
908            )));
909        }
910
911        // Determine APK path
912        let profile_name = match config.profile {
913            BuildProfile::Debug => "debug",
914            BuildProfile::Release => "release",
915        };
916
917        let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name);
918
919        // Try to find APK - check multiple possible filenames
920        // Gradle produces different names depending on signing configuration:
921        // - app-release.apk (signed)
922        // - app-release-unsigned.apk (unsigned release)
923        // - app-debug.apk (debug)
924        let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?;
925
926        Ok(apk_path)
927    }
928
929    /// Finds the APK file in the build output directory
930    ///
931    /// Gradle produces different APK filenames depending on signing configuration:
932    /// - `app-release.apk` - signed release build
933    /// - `app-release-unsigned.apk` - unsigned release build
934    /// - `app-debug.apk` - debug build
935    ///
936    /// This method also checks for `output-metadata.json` which contains the actual
937    /// output filename when present.
938    fn find_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result<PathBuf, BenchError> {
939        // First, try to read output-metadata.json for the actual APK name
940        let metadata_path = apk_dir.join("output-metadata.json");
941        if metadata_path.exists() {
942            if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
943                // Parse the JSON to find the outputFile
944                // Format: {"elements":[{"outputFile":"app-release-unsigned.apk",...}]}
945                if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
946                    let apk_path = apk_dir.join(&apk_name);
947                    if apk_path.exists() {
948                        if self.verbose {
949                            println!("  Found APK from output-metadata.json: {}", apk_path.display());
950                        }
951                        return Ok(apk_path);
952                    }
953                }
954            }
955        }
956
957        // Define candidates in order of preference
958        let candidates = if profile_name == "release" {
959            vec![
960                format!("app-{}.apk", profile_name),           // Signed release
961                format!("app-{}-unsigned.apk", profile_name),  // Unsigned release
962            ]
963        } else {
964            vec![
965                format!("app-{}.apk", profile_name),           // Debug
966            ]
967        };
968
969        // Check each candidate
970        for candidate in &candidates {
971            let apk_path = apk_dir.join(candidate);
972            if apk_path.exists() {
973                if self.verbose {
974                    println!("  Found APK: {}", apk_path.display());
975                }
976                return Ok(apk_path);
977            }
978        }
979
980        // No APK found - provide helpful error message
981        Err(BenchError::Build(format!(
982            "APK not found in {}.\n\n\
983             Gradle task {} reported success but no APK was produced.\n\
984             Searched for:\n{}\n\n\
985             Check the build output directory and rerun ./gradlew {} if needed.",
986            apk_dir.display(),
987            gradle_task,
988            candidates.iter().map(|c| format!("  - {}", c)).collect::<Vec<_>>().join("\n"),
989            gradle_task
990        )))
991    }
992
993    /// Parses output-metadata.json to extract the APK filename
994    ///
995    /// The JSON format is:
996    /// ```json
997    /// {
998    ///   "elements": [
999    ///     {
1000    ///       "outputFile": "app-release-unsigned.apk",
1001    ///       ...
1002    ///     }
1003    ///   ]
1004    /// }
1005    /// ```
1006    fn parse_output_metadata(&self, content: &str) -> Option<String> {
1007        // Simple JSON parsing without external dependencies
1008        // Look for "outputFile":"<filename>"
1009        let pattern = "\"outputFile\"";
1010        if let Some(pos) = content.find(pattern) {
1011            let after_key = &content[pos + pattern.len()..];
1012            // Skip whitespace and colon
1013            let after_colon = after_key.trim_start().strip_prefix(':')?;
1014            let after_ws = after_colon.trim_start();
1015            // Extract the string value
1016            if after_ws.starts_with('"') {
1017                let value_start = &after_ws[1..];
1018                if let Some(end_quote) = value_start.find('"') {
1019                    let filename = &value_start[..end_quote];
1020                    if filename.ends_with(".apk") {
1021                        return Some(filename.to_string());
1022                    }
1023                }
1024            }
1025        }
1026        None
1027    }
1028
1029    /// Builds the Android test APK using Gradle
1030    fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1031        let android_dir = self.output_dir.join("android");
1032
1033        if !android_dir.exists() {
1034            return Err(BenchError::Build(format!(
1035                "Android project not found at {}.\n\n\
1036                 Expected a Gradle project under the output directory.\n\
1037                 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
1038                android_dir.display()
1039            )));
1040        }
1041
1042        let gradle_task = match config.profile {
1043            BuildProfile::Debug => "assembleDebugAndroidTest",
1044            BuildProfile::Release => "assembleReleaseAndroidTest",
1045        };
1046
1047        let mut cmd = Command::new("./gradlew");
1048        cmd.arg(gradle_task).current_dir(&android_dir);
1049
1050        if self.verbose {
1051            cmd.arg("--info");
1052        }
1053
1054        let output = cmd
1055            .output()
1056            .map_err(|e| BenchError::Build(format!(
1057                "Failed to run Gradle wrapper.\n\n\
1058                 Command: ./gradlew {}\n\
1059                 Working directory: {}\n\
1060                 Error: {}\n\n\
1061                 Tips:\n\
1062                 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1063                 - Run ./gradlew --version in that directory to verify the wrapper",
1064                gradle_task,
1065                android_dir.display(),
1066                e
1067            )))?;
1068
1069        if !output.status.success() {
1070            let stdout = String::from_utf8_lossy(&output.stdout);
1071            let stderr = String::from_utf8_lossy(&output.stderr);
1072            return Err(BenchError::Build(format!(
1073                "Gradle test APK build failed.\n\n\
1074                 Command: ./gradlew {}\n\
1075                 Working directory: {}\n\
1076                 Exit status: {}\n\n\
1077                 Stdout:\n{}\n\n\
1078                 Stderr:\n{}\n\n\
1079                 Tips:\n\
1080                 - Re-run with verbose mode to pass --info to Gradle\n\
1081                 - Run ./gradlew {} --stacktrace for a full stack trace",
1082                gradle_task,
1083                android_dir.display(),
1084                output.status,
1085                stdout,
1086                stderr,
1087                gradle_task,
1088            )));
1089        }
1090
1091        let profile_name = match config.profile {
1092            BuildProfile::Debug => "debug",
1093            BuildProfile::Release => "release",
1094        };
1095
1096        let test_apk_dir = android_dir
1097            .join("app/build/outputs/apk/androidTest")
1098            .join(profile_name);
1099
1100        // Find the test APK - use similar logic to main APK
1101        let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?;
1102
1103        Ok(apk_path)
1104    }
1105
1106    /// Finds the test APK file in the build output directory
1107    ///
1108    /// Test APKs can have different naming patterns depending on the build:
1109    /// - `app-debug-androidTest.apk`
1110    /// - `app-release-androidTest.apk`
1111    fn find_test_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result<PathBuf, BenchError> {
1112        // First, try to read output-metadata.json for the actual APK name
1113        let metadata_path = apk_dir.join("output-metadata.json");
1114        if metadata_path.exists() {
1115            if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
1116                if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
1117                    let apk_path = apk_dir.join(&apk_name);
1118                    if apk_path.exists() {
1119                        if self.verbose {
1120                            println!("  Found test APK from output-metadata.json: {}", apk_path.display());
1121                        }
1122                        return Ok(apk_path);
1123                    }
1124                }
1125            }
1126        }
1127
1128        // Check standard naming pattern
1129        let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name));
1130        if apk_path.exists() {
1131            if self.verbose {
1132                println!("  Found test APK: {}", apk_path.display());
1133            }
1134            return Ok(apk_path);
1135        }
1136
1137        // No test APK found
1138        Err(BenchError::Build(format!(
1139            "Android test APK not found in {}.\n\n\
1140             Gradle task {} reported success but no test APK was produced.\n\
1141             Expected: app-{}-androidTest.apk\n\n\
1142             Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.",
1143            apk_dir.display(),
1144            gradle_task,
1145            profile_name,
1146            profile_name,
1147            gradle_task
1148        )))
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    #[test]
1157    fn test_android_builder_creation() {
1158        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1159        assert!(!builder.verbose);
1160        assert_eq!(
1161            builder.output_dir,
1162            PathBuf::from("/tmp/test-project/target/mobench")
1163        );
1164    }
1165
1166    #[test]
1167    fn test_android_builder_verbose() {
1168        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
1169        assert!(builder.verbose);
1170    }
1171
1172    #[test]
1173    fn test_android_builder_custom_output_dir() {
1174        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
1175            .output_dir("/custom/output");
1176        assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
1177    }
1178
1179    #[test]
1180    fn test_parse_output_metadata_unsigned() {
1181        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1182        let metadata = r#"{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"dev.world.bench","variantName":"release","elements":[{"type":"SINGLE","filters":[],"attributes":[],"versionCode":1,"versionName":"0.1","outputFile":"app-release-unsigned.apk"}],"elementType":"File"}"#;
1183        let result = builder.parse_output_metadata(metadata);
1184        assert_eq!(result, Some("app-release-unsigned.apk".to_string()));
1185    }
1186
1187    #[test]
1188    fn test_parse_output_metadata_signed() {
1189        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1190        let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#;
1191        let result = builder.parse_output_metadata(metadata);
1192        assert_eq!(result, Some("app-release.apk".to_string()));
1193    }
1194
1195    #[test]
1196    fn test_parse_output_metadata_no_apk() {
1197        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1198        let metadata = r#"{"version":3,"elements":[]}"#;
1199        let result = builder.parse_output_metadata(metadata);
1200        assert_eq!(result, None);
1201    }
1202
1203    #[test]
1204    fn test_parse_output_metadata_invalid_json() {
1205        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1206        let metadata = "not valid json";
1207        let result = builder.parse_output_metadata(metadata);
1208        assert_eq!(result, None);
1209    }
1210
1211    #[test]
1212    fn test_find_crate_dir_current_directory_is_crate() {
1213        // Test case 1: Current directory IS the crate with matching package name
1214        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current");
1215        let _ = std::fs::remove_dir_all(&temp_dir);
1216        std::fs::create_dir_all(&temp_dir).unwrap();
1217
1218        // Create Cargo.toml with matching package name
1219        std::fs::write(
1220            temp_dir.join("Cargo.toml"),
1221            r#"[package]
1222name = "bench-mobile"
1223version = "0.1.0"
1224"#,
1225        )
1226        .unwrap();
1227
1228        let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1229        let result = builder.find_crate_dir();
1230        assert!(result.is_ok(), "Should find crate in current directory");
1231        assert_eq!(result.unwrap(), temp_dir);
1232
1233        std::fs::remove_dir_all(&temp_dir).unwrap();
1234    }
1235
1236    #[test]
1237    fn test_find_crate_dir_nested_bench_mobile() {
1238        // Test case 2: Crate is in bench-mobile/ subdirectory
1239        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested");
1240        let _ = std::fs::remove_dir_all(&temp_dir);
1241        std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
1242
1243        // Create parent Cargo.toml (workspace or different crate)
1244        std::fs::write(
1245            temp_dir.join("Cargo.toml"),
1246            r#"[workspace]
1247members = ["bench-mobile"]
1248"#,
1249        )
1250        .unwrap();
1251
1252        // Create bench-mobile/Cargo.toml
1253        std::fs::write(
1254            temp_dir.join("bench-mobile/Cargo.toml"),
1255            r#"[package]
1256name = "bench-mobile"
1257version = "0.1.0"
1258"#,
1259        )
1260        .unwrap();
1261
1262        let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1263        let result = builder.find_crate_dir();
1264        assert!(result.is_ok(), "Should find crate in bench-mobile/ directory");
1265        assert_eq!(result.unwrap(), temp_dir.join("bench-mobile"));
1266
1267        std::fs::remove_dir_all(&temp_dir).unwrap();
1268    }
1269
1270    #[test]
1271    fn test_find_crate_dir_crates_subdir() {
1272        // Test case 3: Crate is in crates/{name}/ subdirectory
1273        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates");
1274        let _ = std::fs::remove_dir_all(&temp_dir);
1275        std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
1276
1277        // Create workspace Cargo.toml
1278        std::fs::write(
1279            temp_dir.join("Cargo.toml"),
1280            r#"[workspace]
1281members = ["crates/*"]
1282"#,
1283        )
1284        .unwrap();
1285
1286        // Create crates/my-bench/Cargo.toml
1287        std::fs::write(
1288            temp_dir.join("crates/my-bench/Cargo.toml"),
1289            r#"[package]
1290name = "my-bench"
1291version = "0.1.0"
1292"#,
1293        )
1294        .unwrap();
1295
1296        let builder = AndroidBuilder::new(&temp_dir, "my-bench");
1297        let result = builder.find_crate_dir();
1298        assert!(result.is_ok(), "Should find crate in crates/ directory");
1299        assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench"));
1300
1301        std::fs::remove_dir_all(&temp_dir).unwrap();
1302    }
1303
1304    #[test]
1305    fn test_find_crate_dir_not_found() {
1306        // Test case 4: Crate doesn't exist anywhere
1307        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound");
1308        let _ = std::fs::remove_dir_all(&temp_dir);
1309        std::fs::create_dir_all(&temp_dir).unwrap();
1310
1311        // Create Cargo.toml with DIFFERENT package name
1312        std::fs::write(
1313            temp_dir.join("Cargo.toml"),
1314            r#"[package]
1315name = "some-other-crate"
1316version = "0.1.0"
1317"#,
1318        )
1319        .unwrap();
1320
1321        let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate");
1322        let result = builder.find_crate_dir();
1323        assert!(result.is_err(), "Should fail to find nonexistent crate");
1324        let err_msg = result.unwrap_err().to_string();
1325        assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
1326        assert!(err_msg.contains("Searched locations"));
1327
1328        std::fs::remove_dir_all(&temp_dir).unwrap();
1329    }
1330
1331    #[test]
1332    fn test_find_crate_dir_explicit_crate_path() {
1333        // Test case 5: Explicit crate_dir overrides auto-detection
1334        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit");
1335        let _ = std::fs::remove_dir_all(&temp_dir);
1336        std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
1337
1338        let builder = AndroidBuilder::new(&temp_dir, "any-name")
1339            .crate_dir(temp_dir.join("custom-location"));
1340        let result = builder.find_crate_dir();
1341        assert!(result.is_ok(), "Should use explicit crate_dir");
1342        assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
1343
1344        std::fs::remove_dir_all(&temp_dir).unwrap();
1345    }
1346}