Skip to main content

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