Skip to main content

mobench_sdk/builders/
ios.rs

1//! iOS build automation.
2//!
3//! This module provides [`IosBuilder`] which handles the complete pipeline for
4//! building Rust libraries for iOS and packaging them into an xcframework that
5//! can be used in Xcode projects.
6//!
7//! ## Build Pipeline
8//!
9//! The builder performs these steps:
10//!
11//! 1. **Project scaffolding** - Auto-generates iOS project if missing
12//! 2. **Rust compilation** - Builds static libraries for device and simulator targets
13//! 3. **Binding generation** - Generates UniFFI Swift bindings and C headers
14//! 4. **XCFramework creation** - Creates properly structured xcframework with slices
15//! 5. **Code signing** - Signs the xcframework for Xcode acceptance
16//! 6. **Xcode project generation** - Runs xcodegen if `project.yml` exists
17//!
18//! ## Requirements
19//!
20//! - Xcode with command line tools (`xcode-select --install`)
21//! - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`
22//! - `uniffi-bindgen` for Swift binding generation
23//! - `xcodegen` (optional, `brew install xcodegen`)
24//!
25//! ## Example
26//!
27//! ```ignore
28//! use mobench_sdk::builders::{IosBuilder, SigningMethod};
29//! use mobench_sdk::{BuildConfig, BuildProfile, Target};
30//!
31//! let builder = IosBuilder::new(".", "my-bench-crate")
32//!     .verbose(true)
33//!     .dry_run(false);
34//!
35//! let config = BuildConfig {
36//!     target: Target::Ios,
37//!     profile: BuildProfile::Release,
38//!     incremental: true,
39//! };
40//!
41//! let result = builder.build(&config)?;
42//! println!("XCFramework at: {:?}", result.app_path);
43//!
44//! // Package IPA for BrowserStack or device testing
45//! let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
46//! # Ok::<(), mobench_sdk::BenchError>(())
47//! ```
48//!
49//! ## Dry-Run Mode
50//!
51//! Use `dry_run(true)` to preview the build plan without making changes:
52//!
53//! ```ignore
54//! let builder = IosBuilder::new(".", "my-bench")
55//!     .dry_run(true);
56//!
57//! // This will print the build plan but not execute anything
58//! builder.build(&config)?;
59//! ```
60//!
61//! ## IPA Packaging
62//!
63//! After building the xcframework, you can package an IPA for device testing:
64//!
65//! ```ignore
66//! // Ad-hoc signing (works for BrowserStack, no Apple ID needed)
67//! let ipa = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
68//!
69//! // Development signing (requires Apple Developer account)
70//! let ipa = builder.package_ipa("BenchRunner", SigningMethod::Development)?;
71//! ```
72
73use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
74use crate::codegen::{IosDeploymentTarget, IosProjectOptions, IosRunner, resolve_ios_runner};
75use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
76use std::env;
77use std::fs;
78use std::path::{Path, PathBuf};
79use std::process::Command;
80use std::time::{SystemTime, UNIX_EPOCH};
81
82fn resolve_ios_benchmark_timeout_secs_from_env() -> u64 {
83    env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
84        .ok()
85        .and_then(|raw| raw.parse::<u64>().ok())
86        .filter(|secs| *secs > 0)
87        .unwrap_or(crate::codegen::DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct XcodeVersion {
92    pub major: u16,
93    pub minor: u16,
94    pub raw: String,
95}
96
97fn parse_xcode_version(output: &str) -> Option<XcodeVersion> {
98    let line = output.lines().find(|line| line.starts_with("Xcode "))?;
99    let raw_version = line.trim_start_matches("Xcode ").trim();
100    let mut parts = raw_version.split('.');
101    let major = parts.next()?.parse::<u16>().ok()?;
102    let minor = parts
103        .next()
104        .and_then(|part| part.parse::<u16>().ok())
105        .unwrap_or(0);
106    Some(XcodeVersion {
107        major,
108        minor,
109        raw: raw_version.to_string(),
110    })
111}
112
113fn selected_xcode_version() -> Result<XcodeVersion, BenchError> {
114    let output = Command::new("xcodebuild")
115        .arg("-version")
116        .output()
117        .map_err(|err| {
118            BenchError::Build(format!(
119                "Failed to run `xcodebuild -version`: {err}. Install/select Xcode before building iOS artifacts."
120            ))
121        })?;
122    if !output.status.success() {
123        return Err(BenchError::Build(format!(
124            "`xcodebuild -version` failed with status {}: {}",
125            output.status,
126            String::from_utf8_lossy(&output.stderr)
127        )));
128    }
129    parse_xcode_version(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| {
130        BenchError::Build(format!(
131            "Unable to parse Xcode version from `{}`",
132            String::from_utf8_lossy(&output.stdout).trim()
133        ))
134    })
135}
136
137pub fn minimum_supported_ios_deployment_target_for_xcode(
138    xcode: &XcodeVersion,
139) -> Result<IosDeploymentTarget, BenchError> {
140    let floor = if xcode.major >= 16 { "13.0" } else { "12.0" };
141    IosDeploymentTarget::parse(floor)
142}
143
144pub fn validate_xcode_supports_ios_deployment_target(
145    deployment_target: &IosDeploymentTarget,
146) -> Result<(), BenchError> {
147    let xcode = selected_xcode_version()?;
148    let supported_floor = minimum_supported_ios_deployment_target_for_xcode(&xcode)?;
149    if deployment_target < &supported_floor {
150        return Err(BenchError::Build(format!(
151            "iOS deployment target {deployment_target} requires an older Xcode toolchain; \
152selected Xcode {} supports iOS {}+ in mobench's supported lanes. \
153Use a legacy CI lane with older Xcode, or raise `[ios].deployment_target`.",
154            xcode.raw, supported_floor
155        )));
156    }
157    Ok(())
158}
159
160fn native_c_abi_header(framework_name: &str) -> String {
161    let guard = format!(
162        "{}_MOBENCH_NATIVE_C_ABI_H",
163        framework_name.to_ascii_uppercase()
164    )
165    .replace('-', "_");
166    format!(
167        r#"#ifndef {guard}
168#define {guard}
169
170#include <stdint.h>
171#include <stddef.h>
172
173#ifdef __cplusplus
174extern "C" {{
175#endif
176
177typedef struct MobenchBuf {{
178    uint8_t *ptr;
179    uintptr_t len;
180    uintptr_t cap;
181}} MobenchBuf;
182
183int32_t mobench_run_benchmark_json(const uint8_t *spec_ptr, uintptr_t spec_len, MobenchBuf *out);
184void mobench_free_buf(MobenchBuf *buf);
185const char *mobench_last_error_message(void);
186
187#ifdef __cplusplus
188}}
189#endif
190
191#endif /* {guard} */
192"#,
193    )
194}
195
196/// iOS builder that handles the complete build pipeline.
197///
198/// This builder automates the process of compiling Rust code to iOS static
199/// libraries, generating UniFFI Swift bindings, creating an xcframework,
200/// and optionally packaging an IPA for device deployment.
201///
202/// # Example
203///
204/// ```ignore
205/// use mobench_sdk::builders::{IosBuilder, SigningMethod};
206/// use mobench_sdk::{BuildConfig, BuildProfile, Target};
207///
208/// let builder = IosBuilder::new(".", "my-bench")
209///     .verbose(true)
210///     .output_dir("target/mobench");
211///
212/// let config = BuildConfig {
213///     target: Target::Ios,
214///     profile: BuildProfile::Release,
215///     incremental: true,
216/// };
217///
218/// let result = builder.build(&config)?;
219///
220/// // Optional: Package IPA for device testing
221/// let ipa = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
222/// # Ok::<(), mobench_sdk::BenchError>(())
223/// ```
224pub struct IosBuilder {
225    /// Root directory of the project
226    project_root: PathBuf,
227    /// Output directory for mobile artifacts (defaults to target/mobench)
228    output_dir: PathBuf,
229    /// Name of the bench-mobile crate
230    crate_name: String,
231    /// Whether to use verbose output
232    verbose: bool,
233    /// Optional explicit crate directory (overrides auto-detection)
234    crate_dir: Option<PathBuf>,
235    /// Whether to run in dry-run mode (print what would be done without making changes)
236    dry_run: bool,
237    /// FFI backend used by generated mobile runner scaffolding.
238    ffi_backend: crate::FfiBackend,
239    /// iOS deployment target emitted into generated app and XCUITest targets.
240    deployment_target: IosDeploymentTarget,
241    /// Optional requested runner. If omitted, runner is selected from deployment target.
242    runner: Option<IosRunner>,
243}
244
245impl IosBuilder {
246    /// Creates a new iOS builder
247    ///
248    /// # Arguments
249    ///
250    /// * `project_root` - Root directory containing the bench-mobile crate. This path
251    ///   will be canonicalized to ensure consistent behavior regardless of the current
252    ///   working directory.
253    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
254    pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
255        let root_input = project_root.into();
256        // Canonicalize the path to handle relative paths correctly, regardless of cwd.
257        // Fall back to the input path with an explicit warning so callers know canonicalization
258        // did not succeed.
259        let root = match root_input.canonicalize() {
260            Ok(path) => path,
261            Err(err) => {
262                eprintln!(
263                    "Warning: failed to canonicalize project root `{}`: {}. Using provided path.",
264                    root_input.display(),
265                    err
266                );
267                root_input
268            }
269        };
270        Self {
271            output_dir: root.join("target/mobench"),
272            project_root: root,
273            crate_name: crate_name.into(),
274            verbose: false,
275            crate_dir: None,
276            dry_run: false,
277            ffi_backend: crate::FfiBackend::Uniffi,
278            deployment_target: IosDeploymentTarget::default_target(),
279            runner: None,
280        }
281    }
282
283    /// Sets the output directory for mobile artifacts
284    ///
285    /// By default, artifacts are written to `{project_root}/target/mobench/`.
286    /// Use this to customize the output location.
287    pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
288        self.output_dir = dir.into();
289        self
290    }
291
292    /// Sets the explicit crate directory
293    ///
294    /// By default, the builder searches for the crate in this order:
295    /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name`
296    /// 2. `{project_root}/bench-mobile/` - SDK-generated projects
297    /// 3. `{project_root}/crates/{crate_name}/` - workspace structure
298    /// 4. `{project_root}/{crate_name}/` - simple nested structure
299    ///
300    /// Use this to override auto-detection and point directly to the crate.
301    pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
302        self.crate_dir = Some(dir.into());
303        self
304    }
305
306    /// Enables verbose output
307    pub fn verbose(mut self, verbose: bool) -> Self {
308        self.verbose = verbose;
309        self
310    }
311
312    /// Enables dry-run mode
313    ///
314    /// In dry-run mode, the builder prints what would be done without actually
315    /// making any changes. Useful for previewing the build process.
316    pub fn dry_run(mut self, dry_run: bool) -> Self {
317        self.dry_run = dry_run;
318        self
319    }
320
321    /// Selects the generated runner FFI backend.
322    ///
323    /// The default is UniFFI to preserve existing builder behavior.
324    pub fn ffi_backend(mut self, ffi_backend: crate::FfiBackend) -> Self {
325        self.ffi_backend = ffi_backend;
326        self
327    }
328
329    /// Sets iOS deployment target for generated app and XCUITest targets.
330    pub fn deployment_target(mut self, deployment_target: IosDeploymentTarget) -> Self {
331        self.deployment_target = deployment_target;
332        self
333    }
334
335    /// Sets iOS runner template explicitly.
336    pub fn runner(mut self, runner: Option<IosRunner>) -> Self {
337        self.runner = runner;
338        self
339    }
340
341    /// Builds the iOS app with the given configuration
342    ///
343    /// This performs the following steps:
344    /// 0. Auto-generate project scaffolding if missing
345    /// 1. Build Rust libraries for iOS targets (device + simulator)
346    /// 2. Generate UniFFI Swift bindings and C headers
347    /// 3. Create xcframework with proper structure
348    /// 4. Code-sign the xcframework
349    /// 5. Generate Xcode project with xcodegen (if project.yml exists)
350    ///
351    /// # Returns
352    ///
353    /// * `Ok(BuildResult)` containing the path to the xcframework
354    /// * `Err(BenchError)` if the build fails
355    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
356        // Validate project root before starting build
357        if self.crate_dir.is_none() {
358            validate_project_root(&self.project_root, &self.crate_name)?;
359        }
360        let runner = resolve_ios_runner(&self.deployment_target, self.runner)?;
361        if !self.dry_run {
362            validate_xcode_supports_ios_deployment_target(&self.deployment_target)?;
363        }
364
365        let framework_name = self.crate_name.replace("-", "_");
366        let ios_dir = self.output_dir.join("ios");
367        let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name));
368
369        if self.dry_run {
370            println!("\n[dry-run] iOS build plan:");
371            println!(
372                "  Step 0: Check/generate iOS project scaffolding at {:?}",
373                ios_dir.join("BenchRunner")
374            );
375            if self.ffi_backend.uses_boltffi() {
376                let crate_dir = self.find_crate_dir()?;
377                println!(
378                    "  Step 1: Write BoltFFI config at {:?}",
379                    crate_dir.join("boltffi.toml")
380                );
381                println!("  Step 2: Generate Swift bindings and package xcframework with BoltFFI");
382                println!(
383                    "    Command: boltffi pack apple --layout split --regenerate {}",
384                    if matches!(config.profile, BuildProfile::Release) {
385                        "--release"
386                    } else {
387                        ""
388                    }
389                );
390                println!(
391                    "    Swift output: {:?}",
392                    ios_dir.join("BenchRunner/BenchRunner/Generated/BoltFFIGenerated")
393                );
394                println!("    xcframework output: {:?}", xcframework_path);
395                println!("  Step 3: Generate Xcode project with xcodegen (if project.yml exists)");
396                println!("    Command: xcodegen generate");
397
398                return Ok(BuildResult {
399                    platform: Target::Ios,
400                    app_path: xcframework_path,
401                    test_suite_path: None,
402                    native_libraries: Vec::new(),
403                });
404            }
405
406            println!("  Step 1: Build Rust libraries for iOS targets");
407            println!(
408                "    Command: cargo build --target aarch64-apple-ios --lib {}",
409                if matches!(config.profile, BuildProfile::Release) {
410                    "--release"
411                } else {
412                    ""
413                }
414            );
415            println!(
416                "    Command: cargo build --target aarch64-apple-ios-sim --lib {}",
417                if matches!(config.profile, BuildProfile::Release) {
418                    "--release"
419                } else {
420                    ""
421                }
422            );
423            println!(
424                "    Command: cargo build --target x86_64-apple-ios --lib {}",
425                if matches!(config.profile, BuildProfile::Release) {
426                    "--release"
427                } else {
428                    ""
429                }
430            );
431            if self.ffi_backend.uses_uniffi() {
432                println!("  Step 2: Generate UniFFI Swift bindings");
433                println!(
434                    "    Output: {:?}",
435                    ios_dir.join("BenchRunner/BenchRunner/Generated")
436                );
437            } else {
438                println!(
439                    "  Step 2: Skip UniFFI Swift bindings (backend: {})",
440                    self.ffi_backend
441                );
442            }
443            println!("  Step 3: Create xcframework at {:?}", xcframework_path);
444            println!("    - ios-arm64/{}.framework (device)", framework_name);
445            println!(
446                "    - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)",
447                framework_name
448            );
449            println!("  Step 4: Code-sign xcframework");
450            println!(
451                "    Command: codesign --force --deep --sign - {:?}",
452                xcframework_path
453            );
454            println!("  Step 5: Generate Xcode project with xcodegen (if project.yml exists)");
455            println!("    Command: xcodegen generate");
456
457            // Return a placeholder result for dry-run
458            return Ok(BuildResult {
459                platform: Target::Ios,
460                app_path: xcframework_path,
461                test_suite_path: None,
462                native_libraries: Vec::new(),
463            });
464        }
465
466        // Step 0: Ensure iOS project scaffolding exists
467        // Pass project_root and crate_dir for better benchmark function detection
468        crate::codegen::ensure_ios_project_with_backend_options(
469            &self.output_dir,
470            &self.crate_name,
471            Some(&self.project_root),
472            self.crate_dir.as_deref(),
473            self.ffi_backend,
474            IosProjectOptions {
475                deployment_target: self.deployment_target.clone(),
476                runner,
477                ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs_from_env(),
478            },
479        )?;
480
481        if self.ffi_backend.uses_boltffi() {
482            println!("Generating and packaging BoltFFI iOS bindings...");
483            self.write_boltffi_config()?;
484            let xcframework_path = self.run_boltffi_pack_apple(config)?;
485            self.generate_xcode_project()?;
486
487            let result = BuildResult {
488                platform: Target::Ios,
489                app_path: xcframework_path,
490                test_suite_path: None,
491                native_libraries: Vec::new(),
492            };
493            self.validate_build_artifacts(&result, config)?;
494            return Ok(result);
495        }
496
497        // Step 1: Build Rust libraries
498        println!("Building Rust libraries for iOS...");
499        self.build_rust_libraries(config)?;
500
501        // Step 2: Generate UniFFI bindings when the selected backend needs them
502        if self.ffi_backend.uses_uniffi() {
503            println!("Generating UniFFI Swift bindings...");
504            self.generate_uniffi_bindings()?;
505        } else {
506            println!(
507                "Skipping UniFFI Swift bindings for {} backend",
508                self.ffi_backend
509            );
510        }
511
512        // Step 3: Create xcframework
513        println!("Creating xcframework...");
514        let xcframework_path = self.create_xcframework(config)?;
515
516        // Step 4: Code-sign xcframework
517        println!("Code-signing xcframework...");
518        self.codesign_xcframework(&xcframework_path)?;
519
520        // Copy header to include/ for consumers (handy for CLI uploads)
521        let include_dir = self.output_dir.join("ios/include");
522        fs::create_dir_all(&include_dir).map_err(|e| {
523            BenchError::Build(format!(
524                "Failed to create include dir at {}: {}. Check output directory permissions.",
525                include_dir.display(),
526                e
527            ))
528        })?;
529        let header_dest = include_dir.join(format!("{}.h", framework_name));
530        if self.ffi_backend.uses_uniffi() {
531            let header_src = self
532                .find_uniffi_header(&format!("{}FFI.h", framework_name))
533                .ok_or_else(|| {
534                    BenchError::Build(format!(
535                        "UniFFI header {}FFI.h not found after generation",
536                        framework_name
537                    ))
538                })?;
539            fs::copy(&header_src, &header_dest).map_err(|e| {
540                BenchError::Build(format!(
541                    "Failed to copy UniFFI header to {:?}: {}. Check output directory permissions.",
542                    header_dest, e
543                ))
544            })?;
545        } else {
546            fs::write(&header_dest, native_c_abi_header(&framework_name)).map_err(|e| {
547                BenchError::Build(format!(
548                    "Failed to write native C ABI header to {:?}: {}. Check output directory permissions.",
549                    header_dest, e
550                ))
551            })?;
552        }
553
554        // Step 5: Generate Xcode project if needed
555        self.generate_xcode_project()?;
556
557        // Step 6: Validate all expected artifacts exist
558        let result = BuildResult {
559            platform: Target::Ios,
560            app_path: xcframework_path,
561            test_suite_path: None,
562            native_libraries: Vec::new(),
563        };
564        self.validate_build_artifacts(&result, config)?;
565
566        Ok(result)
567    }
568
569    fn write_boltffi_config(&self) -> Result<(), BenchError> {
570        let crate_dir = self.find_crate_dir()?;
571        let library_name = self.crate_name.replace('-', "_");
572        let package_component = crate::codegen::sanitize_bundle_id_component(&library_name);
573        let kotlin_package = format!("com.mobench.{package_component}");
574        crate::codegen::write_boltffi_config_with_output_dir(
575            &crate_dir,
576            &library_name,
577            &self.crate_name,
578            "BenchRunner",
579            &kotlin_package,
580            &["arm64".to_string()],
581            &self.output_dir,
582        )
583    }
584
585    fn run_boltffi_pack_apple(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
586        let crate_dir = self.find_crate_dir()?;
587        let framework_name = self.crate_name.replace('-', "_");
588        let xcframework_path = self
589            .output_dir
590            .join("ios")
591            .join(format!("{framework_name}.xcframework"));
592        let mut cmd = Command::new("boltffi");
593        cmd.arg("pack")
594            .arg("apple")
595            .arg("--layout")
596            .arg("split")
597            .arg("--regenerate");
598        if matches!(config.profile, BuildProfile::Release) {
599            cmd.arg("--release");
600        }
601        cmd.current_dir(&crate_dir);
602        run_command(cmd, "boltffi pack apple")?;
603        Ok(xcframework_path)
604    }
605
606    /// Validates that all expected build artifacts exist after a successful build
607    fn validate_build_artifacts(
608        &self,
609        result: &BuildResult,
610        config: &BuildConfig,
611    ) -> Result<(), BenchError> {
612        let mut missing = Vec::new();
613        let framework_name = self.crate_name.replace("-", "_");
614        let profile_dir = match config.profile {
615            BuildProfile::Debug => "debug",
616            BuildProfile::Release => "release",
617        };
618
619        // Check xcframework exists
620        if !result.app_path.exists() {
621            missing.push(format!("XCFramework: {}", result.app_path.display()));
622        }
623
624        if self.ffi_backend.uses_boltffi() {
625            let swift_bindings = self
626                .output_dir
627                .join("ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated")
628                .join(boltffi_swift_bindings_path_fragment(&self.crate_name));
629            if !swift_bindings.exists() {
630                missing.push(format!(
631                    "BoltFFI Swift bindings: {}",
632                    swift_bindings.display()
633                ));
634            }
635
636            if !missing.is_empty() {
637                return Err(BenchError::Build(format!(
638                    "BoltFFI iOS build validation failed.\n\nMissing artifacts:\n{}\n\nCheck the boltffi pack apple output above.",
639                    missing
640                        .iter()
641                        .map(|s| format!("  - {}", s))
642                        .collect::<Vec<_>>()
643                        .join("\n")
644                )));
645            }
646            return Ok(());
647        }
648
649        // Check framework slices exist within xcframework
650        let xcframework_path = &result.app_path;
651        let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name));
652        // Combined simulator slice with arm64 + x86_64
653        let sim_slice = xcframework_path.join(format!(
654            "ios-arm64_x86_64-simulator/{}.framework",
655            framework_name
656        ));
657
658        if xcframework_path.exists() {
659            if !device_slice.exists() {
660                missing.push(format!(
661                    "Device framework slice: {}",
662                    device_slice.display()
663                ));
664            }
665            if !sim_slice.exists() {
666                missing.push(format!(
667                    "Simulator framework slice (arm64+x86_64): {}",
668                    sim_slice.display()
669                ));
670            }
671        }
672
673        // Check that static libraries were built
674        let crate_dir = self.find_crate_dir()?;
675        let target_dir = get_cargo_target_dir(&crate_dir)?;
676        let lib_name = format!("lib{}.a", framework_name);
677
678        let device_lib = target_dir
679            .join("aarch64-apple-ios")
680            .join(profile_dir)
681            .join(&lib_name);
682        let sim_arm64_lib = target_dir
683            .join("aarch64-apple-ios-sim")
684            .join(profile_dir)
685            .join(&lib_name);
686        let sim_x86_64_lib = target_dir
687            .join("x86_64-apple-ios")
688            .join(profile_dir)
689            .join(&lib_name);
690
691        if !device_lib.exists() {
692            missing.push(format!("Device static library: {}", device_lib.display()));
693        }
694        if !sim_arm64_lib.exists() {
695            missing.push(format!(
696                "Simulator (arm64) static library: {}",
697                sim_arm64_lib.display()
698            ));
699        }
700        if !sim_x86_64_lib.exists() {
701            missing.push(format!(
702                "Simulator (x86_64) static library: {}",
703                sim_x86_64_lib.display()
704            ));
705        }
706
707        if self.ffi_backend.uses_uniffi() {
708            let swift_bindings = self
709                .output_dir
710                .join("ios/BenchRunner/BenchRunner/Generated")
711                .join(format!("{}.swift", framework_name));
712            if !swift_bindings.exists() {
713                missing.push(format!("Swift bindings: {}", swift_bindings.display()));
714            }
715        }
716
717        if !missing.is_empty() {
718            let critical = missing
719                .iter()
720                .any(|m| m.contains("XCFramework") || m.contains("static library"));
721            if critical {
722                return Err(BenchError::Build(format!(
723                    "Build validation failed: Critical artifacts are missing.\n\n\
724                     Missing artifacts:\n{}\n\n\
725                     This usually means the Rust build step failed. Check the cargo build output above.",
726                    missing
727                        .iter()
728                        .map(|s| format!("  - {}", s))
729                        .collect::<Vec<_>>()
730                        .join("\n")
731                )));
732            } else {
733                eprintln!(
734                    "Warning: Some build artifacts are missing:\n{}\n\
735                     The build may still work but some features might be unavailable.",
736                    missing
737                        .iter()
738                        .map(|s| format!("  - {}", s))
739                        .collect::<Vec<_>>()
740                        .join("\n")
741                );
742            }
743        }
744
745        Ok(())
746    }
747
748    /// Finds the benchmark crate directory.
749    ///
750    /// Search order:
751    /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method
752    /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name
753    /// 3. `{project_root}/bench-mobile/` (SDK projects)
754    /// 4. `{project_root}/crates/{crate_name}/` (repository structure)
755    fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
756        // If explicit crate_dir was provided, use it
757        if let Some(ref dir) = self.crate_dir {
758            if dir.exists() {
759                return Ok(dir.clone());
760            }
761            return Err(BenchError::Build(format!(
762                "Specified crate path does not exist: {:?}.\n\n\
763                 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
764                dir
765            )));
766        }
767
768        // Check if the current directory (project_root) IS the crate
769        // This handles the case where user runs `cargo mobench build` from within the crate directory
770        let root_cargo_toml = self.project_root.join("Cargo.toml");
771        if root_cargo_toml.exists()
772            && let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml)
773            && pkg_name == self.crate_name
774        {
775            return Ok(self.project_root.clone());
776        }
777
778        // Try bench-mobile/ (SDK projects)
779        let bench_mobile_dir = self.project_root.join("bench-mobile");
780        if bench_mobile_dir.exists() {
781            return Ok(bench_mobile_dir);
782        }
783
784        // Try crates/{crate_name}/ (repository structure)
785        let crates_dir = self.project_root.join("crates").join(&self.crate_name);
786        if crates_dir.exists() {
787            return Ok(crates_dir);
788        }
789
790        // Also try {crate_name}/ in project root (common pattern)
791        let named_dir = self.project_root.join(&self.crate_name);
792        if named_dir.exists() {
793            return Ok(named_dir);
794        }
795
796        let root_manifest = root_cargo_toml;
797        let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
798        let crates_manifest = crates_dir.join("Cargo.toml");
799        let named_manifest = named_dir.join("Cargo.toml");
800        Err(BenchError::Build(format!(
801            "Benchmark crate '{}' not found.\n\n\
802             Searched locations:\n\
803             - {} (checked [package] name)\n\
804             - {}\n\
805             - {}\n\
806             - {}\n\n\
807             To fix this:\n\
808             1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
809             2. Create a bench-mobile/ directory with your benchmark crate, or\n\
810             3. Use --crate-path to specify the benchmark crate location:\n\
811                cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\
812             Common issues:\n\
813             - Typo in crate name (check Cargo.toml [package] name)\n\
814             - Wrong working directory (run from project root)\n\
815             - Missing Cargo.toml in the crate directory\n\n\
816             Run 'cargo mobench init --help' to generate a new benchmark project.",
817            self.crate_name,
818            root_manifest.display(),
819            bench_mobile_manifest.display(),
820            crates_manifest.display(),
821            named_manifest.display(),
822            self.crate_name,
823        )))
824    }
825
826    /// Builds Rust libraries for iOS targets
827    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
828        let crate_dir = self.find_crate_dir()?;
829
830        // iOS targets: device and simulator (both arm64 and x86_64 for Intel Macs)
831        let targets = vec![
832            "aarch64-apple-ios",     // Device (ARM64)
833            "aarch64-apple-ios-sim", // Simulator (Apple Silicon Macs)
834            "x86_64-apple-ios",      // Simulator (Intel Macs)
835        ];
836
837        // Check if targets are installed
838        self.check_rust_targets(&targets)?;
839        let release_flag = if matches!(config.profile, BuildProfile::Release) {
840            "--release"
841        } else {
842            ""
843        };
844
845        for target in targets {
846            if self.verbose {
847                println!("  Building for {}", target);
848            }
849
850            let mut cmd = Command::new("cargo");
851            cmd.arg("build").arg("--target").arg(target).arg("--lib");
852
853            // Add release flag if needed
854            if !release_flag.is_empty() {
855                cmd.arg(release_flag);
856            }
857
858            // Set working directory
859            cmd.current_dir(&crate_dir);
860
861            // Execute build
862            let command_hint = if release_flag.is_empty() {
863                format!("cargo build --target {} --lib", target)
864            } else {
865                format!("cargo build --target {} --lib {}", target, release_flag)
866            };
867            let output = cmd.output().map_err(|e| {
868                BenchError::Build(format!(
869                    "Failed to run cargo for {}.\n\n\
870                     Command: {}\n\
871                     Crate directory: {}\n\
872                     Error: {}\n\n\
873                     Tip: ensure cargo is installed and on PATH.",
874                    target,
875                    command_hint,
876                    crate_dir.display(),
877                    e
878                ))
879            })?;
880
881            if !output.status.success() {
882                let stdout = String::from_utf8_lossy(&output.stdout);
883                let stderr = String::from_utf8_lossy(&output.stderr);
884                return Err(BenchError::Build(format!(
885                    "cargo build failed for {}.\n\n\
886                     Command: {}\n\
887                     Crate directory: {}\n\
888                     Exit status: {}\n\n\
889                     Stdout:\n{}\n\n\
890                     Stderr:\n{}\n\n\
891                     Tips:\n\
892                     - Ensure Xcode command line tools are installed (xcode-select --install)\n\
893                     - Confirm Rust targets are installed (rustup target add {})",
894                    target,
895                    command_hint,
896                    crate_dir.display(),
897                    output.status,
898                    stdout,
899                    stderr,
900                    target
901                )));
902            }
903        }
904
905        Ok(())
906    }
907
908    /// Checks if required Rust targets are installed.
909    ///
910    /// Uses `rustc --print sysroot` to locate the actual sysroot (respects
911    /// RUSTUP_TOOLCHAIN and toolchain overrides) instead of `rustup target list`
912    /// which may query a different toolchain in CI.
913    fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
914        let sysroot = Command::new("rustc")
915            .args(["--print", "sysroot"])
916            .output()
917            .ok()
918            .and_then(|o| {
919                if o.status.success() {
920                    String::from_utf8(o.stdout).ok()
921                } else {
922                    None
923                }
924            })
925            .map(|s| s.trim().to_string());
926
927        for target in targets {
928            let installed = if let Some(ref root) = sysroot {
929                // Check if the target's stdlib exists in the active sysroot
930                let lib_dir =
931                    std::path::Path::new(root).join(format!("lib/rustlib/{}/lib", target));
932                lib_dir.exists()
933            } else {
934                // Fallback: ask rustup (may query wrong toolchain in CI)
935                let output = Command::new("rustup")
936                    .args(["target", "list", "--installed"])
937                    .output()
938                    .ok();
939                output
940                    .map(|o| String::from_utf8_lossy(&o.stdout).contains(target))
941                    .unwrap_or(false)
942            };
943
944            if !installed {
945                return Err(BenchError::Build(format!(
946                    "Rust target '{}' is not installed.\n\n\
947                     This target is required to compile for iOS.\n\n\
948                     To install:\n\
949                       rustup target add {}\n\n\
950                     For a complete iOS setup, you need all three:\n\
951                       rustup target add aarch64-apple-ios        # Device\n\
952                       rustup target add aarch64-apple-ios-sim    # Simulator (Apple Silicon)\n\
953                       rustup target add x86_64-apple-ios         # Simulator (Intel Macs)",
954                    target, target
955                )));
956            }
957        }
958
959        Ok(())
960    }
961
962    /// Generates UniFFI Swift bindings
963    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
964        let crate_dir = self.find_crate_dir()?;
965        let crate_name_underscored = self.crate_name.replace("-", "_");
966
967        // Prefer fresh bindings so schema changes in BenchReport stay in sync with the app.
968        // Fall back to pre-generated bindings only if generation tooling is unavailable.
969        let bindings_path = self
970            .output_dir
971            .join("ios")
972            .join("BenchRunner")
973            .join("BenchRunner")
974            .join("Generated")
975            .join(format!("{}.swift", crate_name_underscored));
976        let had_existing_bindings = bindings_path.exists();
977        if had_existing_bindings && self.verbose {
978            println!(
979                "  Found existing Swift bindings at {:?}; regenerating to keep the UniFFI schema current",
980                bindings_path
981            );
982        }
983
984        // Build host library to feed uniffi-bindgen
985        let mut build_cmd = Command::new("cargo");
986        build_cmd.arg("build");
987        build_cmd.current_dir(&crate_dir);
988        run_command(build_cmd, "cargo build (host)")?;
989
990        let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
991        let out_dir = self
992            .output_dir
993            .join("ios")
994            .join("BenchRunner")
995            .join("BenchRunner")
996            .join("Generated");
997        fs::create_dir_all(&out_dir).map_err(|e| {
998            BenchError::Build(format!(
999                "Failed to create Swift bindings dir at {}: {}. Check output directory permissions.",
1000                out_dir.display(),
1001                e
1002            ))
1003        })?;
1004
1005        // Try cargo run first (works if crate has uniffi-bindgen binary target)
1006        let cargo_run_result = Command::new("cargo")
1007            .args([
1008                "run",
1009                "-p",
1010                &self.crate_name,
1011                "--bin",
1012                "uniffi-bindgen",
1013                "--",
1014            ])
1015            .arg("generate")
1016            .arg("--library")
1017            .arg(&lib_path)
1018            .arg("--language")
1019            .arg("swift")
1020            .arg("--out-dir")
1021            .arg(&out_dir)
1022            .current_dir(&crate_dir)
1023            .output();
1024
1025        let use_cargo_run = cargo_run_result
1026            .as_ref()
1027            .map(|o| o.status.success())
1028            .unwrap_or(false);
1029
1030        if use_cargo_run {
1031            if self.verbose {
1032                println!("  Generated bindings using cargo run uniffi-bindgen");
1033            }
1034        } else {
1035            // Fall back to global uniffi-bindgen
1036            let uniffi_available = Command::new("uniffi-bindgen")
1037                .arg("--version")
1038                .output()
1039                .map(|o| o.status.success())
1040                .unwrap_or(false);
1041
1042            if !uniffi_available {
1043                if had_existing_bindings {
1044                    if self.verbose {
1045                        println!(
1046                            "  Warning: uniffi-bindgen is unavailable; keeping existing Swift bindings at {:?}",
1047                            bindings_path
1048                        );
1049                    }
1050                    return Ok(());
1051                }
1052                return Err(BenchError::Build(
1053                    "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
1054                     To fix this, either:\n\
1055                     1. Add a uniffi-bindgen binary to your crate:\n\
1056                        [[bin]]\n\
1057                        name = \"uniffi-bindgen\"\n\
1058                        path = \"src/bin/uniffi-bindgen.rs\"\n\n\
1059                     2. Or install a matching uniffi-bindgen CLI globally:\n\
1060                        cargo install --git https://github.com/mozilla/uniffi-rs --tag <uniffi-tag> uniffi-bindgen-cli --bin uniffi-bindgen\n\n\
1061                     3. Or pre-generate bindings and commit them."
1062                        .to_string(),
1063                ));
1064            }
1065
1066            let mut cmd = Command::new("uniffi-bindgen");
1067            cmd.arg("generate")
1068                .arg("--library")
1069                .arg(&lib_path)
1070                .arg("--language")
1071                .arg("swift")
1072                .arg("--out-dir")
1073                .arg(&out_dir);
1074            if let Err(error) = run_command(cmd, "uniffi-bindgen swift") {
1075                if had_existing_bindings {
1076                    if self.verbose {
1077                        println!(
1078                            "  Warning: failed to regenerate Swift bindings ({error}); keeping existing bindings at {:?}",
1079                            bindings_path
1080                        );
1081                    }
1082                    return Ok(());
1083                }
1084                return Err(error);
1085            }
1086        }
1087
1088        if self.verbose {
1089            println!("  Generated UniFFI Swift bindings at {:?}", out_dir);
1090        }
1091
1092        Ok(())
1093    }
1094
1095    /// Creates an xcframework from the built libraries
1096    fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1097        let profile_dir = match config.profile {
1098            BuildProfile::Debug => "debug",
1099            BuildProfile::Release => "release",
1100        };
1101
1102        let crate_dir = self.find_crate_dir()?;
1103        let target_dir = get_cargo_target_dir(&crate_dir)?;
1104        let xcframework_dir = self.output_dir.join("ios");
1105        let framework_name = &self.crate_name.replace("-", "_");
1106        let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
1107
1108        // Remove existing xcframework if it exists
1109        if xcframework_path.exists() {
1110            fs::remove_dir_all(&xcframework_path).map_err(|e| {
1111                BenchError::Build(format!(
1112                    "Failed to remove old xcframework at {}: {}. Close any tools using it and retry.",
1113                    xcframework_path.display(),
1114                    e
1115                ))
1116            })?;
1117        }
1118
1119        // Create xcframework directory
1120        fs::create_dir_all(&xcframework_dir).map_err(|e| {
1121            BenchError::Build(format!(
1122                "Failed to create xcframework directory at {}: {}. Check output directory permissions.",
1123                xcframework_dir.display(),
1124                e
1125            ))
1126        })?;
1127
1128        // Build framework structure for each platform
1129        // Device slice (arm64 only)
1130        self.create_framework_slice(
1131            &target_dir.join("aarch64-apple-ios").join(profile_dir),
1132            &xcframework_path.join("ios-arm64"),
1133            framework_name,
1134            "ios",
1135            self.ffi_backend,
1136        )?;
1137
1138        // Simulator slice (arm64 + x86_64 combined via lipo for both Apple Silicon and Intel Macs)
1139        self.create_simulator_framework_slice(
1140            &target_dir,
1141            profile_dir,
1142            &xcframework_path.join("ios-arm64_x86_64-simulator"),
1143            framework_name,
1144            self.ffi_backend,
1145        )?;
1146
1147        // Create xcframework Info.plist
1148        self.create_xcframework_plist(&xcframework_path, framework_name)?;
1149
1150        Ok(xcframework_path)
1151    }
1152
1153    /// Creates a framework slice for a specific platform
1154    fn create_framework_slice(
1155        &self,
1156        lib_path: &Path,
1157        output_dir: &Path,
1158        framework_name: &str,
1159        platform: &str,
1160        ffi_backend: crate::FfiBackend,
1161    ) -> Result<(), BenchError> {
1162        let framework_dir = output_dir.join(format!("{}.framework", framework_name));
1163        let headers_dir = framework_dir.join("Headers");
1164
1165        // Create directories
1166        fs::create_dir_all(&headers_dir).map_err(|e| {
1167            BenchError::Build(format!(
1168                "Failed to create framework directories at {}: {}. Check output directory permissions.",
1169                headers_dir.display(),
1170                e
1171            ))
1172        })?;
1173
1174        // Copy static library
1175        let src_lib = lib_path.join(format!("lib{}.a", framework_name));
1176        let dest_lib = framework_dir.join(framework_name);
1177
1178        if !src_lib.exists() {
1179            return Err(BenchError::Build(format!(
1180                "Static library not found at {}.\n\n\
1181                 Expected output from cargo build --target <target> --lib.\n\
1182                 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
1183                src_lib.display()
1184            )));
1185        }
1186
1187        fs::copy(&src_lib, &dest_lib).map_err(|e| {
1188            BenchError::Build(format!(
1189                "Failed to copy static library from {} to {}: {}. Check output directory permissions.",
1190                src_lib.display(),
1191                dest_lib.display(),
1192                e
1193            ))
1194        })?;
1195
1196        // Copy or generate the backend-specific header into the framework
1197        let header_name = format!("{}FFI.h", framework_name);
1198        let dest_header = headers_dir.join(&header_name);
1199        if ffi_backend.uses_uniffi() {
1200            let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
1201                BenchError::Build(format!(
1202                    "UniFFI header {} not found; run binding generation before building",
1203                    header_name
1204                ))
1205            })?;
1206            fs::copy(&header_path, &dest_header).map_err(|e| {
1207                BenchError::Build(format!(
1208                    "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
1209                    header_path.display(),
1210                    dest_header.display(),
1211                    e
1212                ))
1213            })?;
1214        } else {
1215            fs::write(&dest_header, native_c_abi_header(framework_name)).map_err(|e| {
1216                BenchError::Build(format!(
1217                    "Failed to write native C ABI header to {}: {}. Check output directory permissions.",
1218                    dest_header.display(),
1219                    e
1220                ))
1221            })?;
1222        }
1223
1224        // Create module.modulemap
1225        let modulemap_content = format!(
1226            "framework module {} {{\n  umbrella header \"{}FFI.h\"\n  export *\n  module * {{ export * }}\n}}",
1227            framework_name, framework_name
1228        );
1229        let modulemap_path = headers_dir.join("module.modulemap");
1230        fs::write(&modulemap_path, modulemap_content).map_err(|e| {
1231            BenchError::Build(format!(
1232                "Failed to write module.modulemap at {}: {}. Check output directory permissions.",
1233                modulemap_path.display(),
1234                e
1235            ))
1236        })?;
1237
1238        // Create framework Info.plist
1239        self.create_framework_plist(&framework_dir, framework_name, platform)?;
1240
1241        Ok(())
1242    }
1243
1244    /// Creates a combined simulator framework slice with arm64 + x86_64 using lipo
1245    fn create_simulator_framework_slice(
1246        &self,
1247        target_dir: &Path,
1248        profile_dir: &str,
1249        output_dir: &Path,
1250        framework_name: &str,
1251        ffi_backend: crate::FfiBackend,
1252    ) -> Result<(), BenchError> {
1253        let framework_dir = output_dir.join(format!("{}.framework", framework_name));
1254        let headers_dir = framework_dir.join("Headers");
1255
1256        // Create directories
1257        fs::create_dir_all(&headers_dir).map_err(|e| {
1258            BenchError::Build(format!(
1259                "Failed to create framework directories at {}: {}. Check output directory permissions.",
1260                headers_dir.display(),
1261                e
1262            ))
1263        })?;
1264
1265        // Paths to the simulator libraries
1266        let arm64_lib = target_dir
1267            .join("aarch64-apple-ios-sim")
1268            .join(profile_dir)
1269            .join(format!("lib{}.a", framework_name));
1270        let x86_64_lib = target_dir
1271            .join("x86_64-apple-ios")
1272            .join(profile_dir)
1273            .join(format!("lib{}.a", framework_name));
1274
1275        // Check that both libraries exist
1276        if !arm64_lib.exists() {
1277            return Err(BenchError::Build(format!(
1278                "Simulator library (arm64) not found at {}.\n\n\
1279                 Expected output from cargo build --target aarch64-apple-ios-sim --lib.\n\
1280                 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
1281                arm64_lib.display()
1282            )));
1283        }
1284        if !x86_64_lib.exists() {
1285            return Err(BenchError::Build(format!(
1286                "Simulator library (x86_64) not found at {}.\n\n\
1287                 Expected output from cargo build --target x86_64-apple-ios --lib.\n\
1288                 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
1289                x86_64_lib.display()
1290            )));
1291        }
1292
1293        // Use lipo to combine arm64 and x86_64 into a universal binary
1294        let dest_lib = framework_dir.join(framework_name);
1295        let output = Command::new("lipo")
1296            .arg("-create")
1297            .arg(&arm64_lib)
1298            .arg(&x86_64_lib)
1299            .arg("-output")
1300            .arg(&dest_lib)
1301            .output()
1302            .map_err(|e| {
1303                BenchError::Build(format!(
1304                    "Failed to run lipo to create universal simulator binary.\n\n\
1305                     Command: lipo -create {} {} -output {}\n\
1306                     Error: {}\n\n\
1307                     Ensure Xcode command line tools are installed: xcode-select --install",
1308                    arm64_lib.display(),
1309                    x86_64_lib.display(),
1310                    dest_lib.display(),
1311                    e
1312                ))
1313            })?;
1314
1315        if !output.status.success() {
1316            let stderr = String::from_utf8_lossy(&output.stderr);
1317            return Err(BenchError::Build(format!(
1318                "lipo failed to create universal simulator binary.\n\n\
1319                 Command: lipo -create {} {} -output {}\n\
1320                 Exit status: {}\n\
1321                 Stderr: {}\n\n\
1322                 Ensure both libraries are valid static libraries.",
1323                arm64_lib.display(),
1324                x86_64_lib.display(),
1325                dest_lib.display(),
1326                output.status,
1327                stderr
1328            )));
1329        }
1330
1331        if self.verbose {
1332            println!(
1333                "  Created universal simulator binary (arm64 + x86_64) at {:?}",
1334                dest_lib
1335            );
1336        }
1337
1338        // Copy or generate the backend-specific header into the framework
1339        let header_name = format!("{}FFI.h", framework_name);
1340        let dest_header = headers_dir.join(&header_name);
1341        if ffi_backend.uses_uniffi() {
1342            let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
1343                BenchError::Build(format!(
1344                    "UniFFI header {} not found; run binding generation before building",
1345                    header_name
1346                ))
1347            })?;
1348            fs::copy(&header_path, &dest_header).map_err(|e| {
1349                BenchError::Build(format!(
1350                    "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
1351                    header_path.display(),
1352                    dest_header.display(),
1353                    e
1354                ))
1355            })?;
1356        } else {
1357            fs::write(&dest_header, native_c_abi_header(framework_name)).map_err(|e| {
1358                BenchError::Build(format!(
1359                    "Failed to write native C ABI header to {}: {}. Check output directory permissions.",
1360                    dest_header.display(),
1361                    e
1362                ))
1363            })?;
1364        }
1365
1366        // Create module.modulemap
1367        let modulemap_content = format!(
1368            "framework module {} {{\n  umbrella header \"{}FFI.h\"\n  export *\n  module * {{ export * }}\n}}",
1369            framework_name, framework_name
1370        );
1371        let modulemap_path = headers_dir.join("module.modulemap");
1372        fs::write(&modulemap_path, modulemap_content).map_err(|e| {
1373            BenchError::Build(format!(
1374                "Failed to write module.modulemap at {}: {}. Check output directory permissions.",
1375                modulemap_path.display(),
1376                e
1377            ))
1378        })?;
1379
1380        // Create framework Info.plist (uses "ios-simulator" platform)
1381        self.create_framework_plist(&framework_dir, framework_name, "ios-simulator")?;
1382
1383        Ok(())
1384    }
1385
1386    /// Creates Info.plist for a framework slice
1387    fn create_framework_plist(
1388        &self,
1389        framework_dir: &Path,
1390        framework_name: &str,
1391        platform: &str,
1392    ) -> Result<(), BenchError> {
1393        // Sanitize bundle ID to only contain alphanumeric characters (no hyphens or underscores)
1394        // iOS bundle identifiers should be alphanumeric with dots separating components
1395        let bundle_id: String = framework_name
1396            .chars()
1397            .filter(|c| c.is_ascii_alphanumeric())
1398            .collect::<String>()
1399            .to_lowercase();
1400        let plist_content = format!(
1401            r#"<?xml version="1.0" encoding="UTF-8"?>
1402<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1403<plist version="1.0">
1404<dict>
1405    <key>CFBundleExecutable</key>
1406    <string>{}</string>
1407    <key>CFBundleIdentifier</key>
1408    <string>dev.world.{}</string>
1409    <key>CFBundleInfoDictionaryVersion</key>
1410    <string>6.0</string>
1411    <key>CFBundleName</key>
1412    <string>{}</string>
1413    <key>CFBundlePackageType</key>
1414    <string>FMWK</string>
1415    <key>CFBundleShortVersionString</key>
1416    <string>0.1.0</string>
1417    <key>CFBundleVersion</key>
1418    <string>1</string>
1419    <key>CFBundleSupportedPlatforms</key>
1420    <array>
1421        <string>{}</string>
1422    </array>
1423</dict>
1424</plist>"#,
1425            framework_name,
1426            bundle_id,
1427            framework_name,
1428            if platform == "ios" {
1429                "iPhoneOS"
1430            } else {
1431                "iPhoneSimulator"
1432            }
1433        );
1434
1435        let plist_path = framework_dir.join("Info.plist");
1436        fs::write(&plist_path, plist_content).map_err(|e| {
1437            BenchError::Build(format!(
1438                "Failed to write framework Info.plist at {}: {}. Check output directory permissions.",
1439                plist_path.display(),
1440                e
1441            ))
1442        })?;
1443
1444        Ok(())
1445    }
1446
1447    /// Creates xcframework Info.plist
1448    fn create_xcframework_plist(
1449        &self,
1450        xcframework_path: &Path,
1451        framework_name: &str,
1452    ) -> Result<(), BenchError> {
1453        let plist_content = format!(
1454            r#"<?xml version="1.0" encoding="UTF-8"?>
1455<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1456<plist version="1.0">
1457<dict>
1458    <key>AvailableLibraries</key>
1459    <array>
1460        <dict>
1461            <key>LibraryIdentifier</key>
1462            <string>ios-arm64</string>
1463            <key>LibraryPath</key>
1464            <string>{}.framework</string>
1465            <key>SupportedArchitectures</key>
1466            <array>
1467                <string>arm64</string>
1468            </array>
1469            <key>SupportedPlatform</key>
1470            <string>ios</string>
1471        </dict>
1472        <dict>
1473            <key>LibraryIdentifier</key>
1474            <string>ios-arm64_x86_64-simulator</string>
1475            <key>LibraryPath</key>
1476            <string>{}.framework</string>
1477            <key>SupportedArchitectures</key>
1478            <array>
1479                <string>arm64</string>
1480                <string>x86_64</string>
1481            </array>
1482            <key>SupportedPlatform</key>
1483            <string>ios</string>
1484            <key>SupportedPlatformVariant</key>
1485            <string>simulator</string>
1486        </dict>
1487    </array>
1488    <key>CFBundlePackageType</key>
1489    <string>XFWK</string>
1490    <key>XCFrameworkFormatVersion</key>
1491    <string>1.0</string>
1492</dict>
1493</plist>"#,
1494            framework_name, framework_name
1495        );
1496
1497        let plist_path = xcframework_path.join("Info.plist");
1498        fs::write(&plist_path, plist_content).map_err(|e| {
1499            BenchError::Build(format!(
1500                "Failed to write xcframework Info.plist at {}: {}. Check output directory permissions.",
1501                plist_path.display(),
1502                e
1503            ))
1504        })?;
1505
1506        Ok(())
1507    }
1508
1509    /// Code-signs the xcframework
1510    ///
1511    /// # Errors
1512    ///
1513    /// Returns an error if codesign is not available or if signing fails.
1514    /// The xcframework must be signed for Xcode to accept it.
1515    fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
1516        let output = Command::new("codesign")
1517            .arg("--force")
1518            .arg("--deep")
1519            .arg("--sign")
1520            .arg("-")
1521            .arg(xcframework_path)
1522            .output()
1523            .map_err(|e| {
1524                BenchError::Build(format!(
1525                    "Failed to run codesign.\n\n\
1526                     XCFramework: {}\n\
1527                     Error: {}\n\n\
1528                     Ensure Xcode command line tools are installed:\n\
1529                       xcode-select --install\n\n\
1530                     The xcframework must be signed for Xcode to accept it.",
1531                    xcframework_path.display(),
1532                    e
1533                ))
1534            })?;
1535
1536        if output.status.success() {
1537            if self.verbose {
1538                println!("  Successfully code-signed xcframework");
1539            }
1540            Ok(())
1541        } else {
1542            let stderr = String::from_utf8_lossy(&output.stderr);
1543            Err(BenchError::Build(format!(
1544                "codesign failed to sign xcframework.\n\n\
1545                 XCFramework: {}\n\
1546                 Exit status: {}\n\
1547                 Stderr: {}\n\n\
1548                 Ensure you have valid signing credentials:\n\
1549                   security find-identity -v -p codesigning\n\n\
1550                 For ad-hoc signing (most common), the '-' identity should work.\n\
1551                 If signing continues to fail, check that the xcframework structure is valid.",
1552                xcframework_path.display(),
1553                output.status,
1554                stderr
1555            )))
1556        }
1557    }
1558
1559    /// Generates Xcode project using xcodegen if project.yml exists
1560    ///
1561    /// # Errors
1562    ///
1563    /// Returns an error if:
1564    /// - xcodegen is not installed and project.yml exists
1565    /// - xcodegen execution fails
1566    ///
1567    /// If project.yml does not exist, this function returns Ok(()) silently.
1568    fn generate_xcode_project(&self) -> Result<(), BenchError> {
1569        let ios_dir = self.output_dir.join("ios");
1570        let project_yml = ios_dir.join("BenchRunner/project.yml");
1571
1572        if !project_yml.exists() {
1573            if self.verbose {
1574                println!("  No project.yml found, skipping xcodegen");
1575            }
1576            return Ok(());
1577        }
1578
1579        if self.verbose {
1580            println!("  Generating Xcode project with xcodegen");
1581        }
1582
1583        let project_dir = ios_dir.join("BenchRunner");
1584        let output = Command::new("xcodegen")
1585            .arg("generate")
1586            .current_dir(&project_dir)
1587            .output()
1588            .map_err(|e| {
1589                BenchError::Build(format!(
1590                    "Failed to run xcodegen.\n\n\
1591                     project.yml found at: {}\n\
1592                     Working directory: {}\n\
1593                     Error: {}\n\n\
1594                     xcodegen is required to generate the Xcode project.\n\
1595                     Install it with:\n\
1596                       brew install xcodegen\n\n\
1597                     After installation, re-run the build.",
1598                    project_yml.display(),
1599                    project_dir.display(),
1600                    e
1601                ))
1602            })?;
1603
1604        if output.status.success() {
1605            if self.verbose {
1606                println!("  Successfully generated Xcode project");
1607            }
1608            Ok(())
1609        } else {
1610            let stdout = String::from_utf8_lossy(&output.stdout);
1611            let stderr = String::from_utf8_lossy(&output.stderr);
1612            Err(BenchError::Build(format!(
1613                "xcodegen failed.\n\n\
1614                 Command: xcodegen generate\n\
1615                 Working directory: {}\n\
1616                 Exit status: {}\n\n\
1617                 Stdout:\n{}\n\n\
1618                 Stderr:\n{}\n\n\
1619                 Check that project.yml is valid YAML and has correct xcodegen syntax.\n\
1620                 Try running 'xcodegen generate' manually in {} for more details.",
1621                project_dir.display(),
1622                output.status,
1623                stdout,
1624                stderr,
1625                project_dir.display()
1626            )))
1627        }
1628    }
1629
1630    /// Locate the generated UniFFI header for the crate
1631    fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
1632        // Check generated Swift bindings directory first
1633        let swift_dir = self
1634            .output_dir
1635            .join("ios/BenchRunner/BenchRunner/Generated");
1636        let candidate_swift = swift_dir.join(header_name);
1637        if candidate_swift.exists() {
1638            return Some(candidate_swift);
1639        }
1640
1641        // Get the actual target directory (handles workspace case)
1642        let crate_dir = self.find_crate_dir().ok()?;
1643        let target_dir = get_cargo_target_dir(&crate_dir).ok()?;
1644        // Common UniFFI output location when using uniffi::generate_scaffolding
1645        let candidate = target_dir.join("uniffi").join(header_name);
1646        if candidate.exists() {
1647            return Some(candidate);
1648        }
1649
1650        // Fallback: walk the target directory for the header
1651        let mut stack = vec![target_dir];
1652        while let Some(dir) = stack.pop() {
1653            if let Ok(entries) = fs::read_dir(&dir) {
1654                for entry in entries.flatten() {
1655                    let path = entry.path();
1656                    if path.is_dir() {
1657                        // Limit depth by skipping non-target subtrees such as incremental caches
1658                        if let Some(name) = path.file_name().and_then(|n| n.to_str())
1659                            && name == "incremental"
1660                        {
1661                            continue;
1662                        }
1663                        stack.push(path);
1664                    } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
1665                        && name == header_name
1666                    {
1667                        return Some(path);
1668                    }
1669                }
1670            }
1671        }
1672
1673        None
1674    }
1675}
1676
1677#[allow(clippy::collapsible_if)]
1678fn find_codesign_identity() -> Option<String> {
1679    let output = Command::new("security")
1680        .args(["find-identity", "-v", "-p", "codesigning"])
1681        .output()
1682        .ok()?;
1683    if !output.status.success() {
1684        return None;
1685    }
1686    let stdout = String::from_utf8_lossy(&output.stdout);
1687    let mut identities = Vec::new();
1688    for line in stdout.lines() {
1689        if let Some(start) = line.find('"') {
1690            if let Some(end) = line[start + 1..].find('"') {
1691                identities.push(line[start + 1..start + 1 + end].to_string());
1692            }
1693        }
1694    }
1695    let preferred = [
1696        "Apple Distribution",
1697        "iPhone Distribution",
1698        "Apple Development",
1699        "iPhone Developer",
1700    ];
1701    for label in preferred {
1702        if let Some(identity) = identities.iter().find(|i| i.contains(label)) {
1703            return Some(identity.clone());
1704        }
1705    }
1706    identities.first().cloned()
1707}
1708
1709#[allow(clippy::collapsible_if)]
1710fn find_provisioning_profile() -> Option<PathBuf> {
1711    if let Ok(path) = env::var("MOBENCH_IOS_PROFILE") {
1712        let profile = PathBuf::from(path);
1713        if profile.exists() {
1714            return Some(profile);
1715        }
1716    }
1717    let home = env::var("HOME").ok()?;
1718    let profiles_dir = PathBuf::from(home).join("Library/MobileDevice/Provisioning Profiles");
1719    let entries = fs::read_dir(&profiles_dir).ok()?;
1720    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
1721    for entry in entries.flatten() {
1722        let path = entry.path();
1723        if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") {
1724            continue;
1725        }
1726        if let Ok(metadata) = entry.metadata()
1727            && let Ok(modified) = metadata.modified()
1728        {
1729            match &newest {
1730                Some((current, _)) if *current >= modified => {}
1731                _ => newest = Some((modified, path)),
1732            }
1733        }
1734    }
1735    newest.map(|(_, path)| path)
1736}
1737
1738fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), BenchError> {
1739    let dest = app_path.join("embedded.mobileprovision");
1740    fs::copy(profile, &dest).map_err(|e| {
1741        BenchError::Build(format!(
1742            "Failed to embed provisioning profile at {:?}: {}. Check the profile path and file permissions.",
1743            dest, e
1744        ))
1745    })?;
1746    Ok(())
1747}
1748
1749fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> {
1750    let output = Command::new("codesign")
1751        .args(["--force", "--deep", "--sign", identity])
1752        .arg(app_path)
1753        .output()
1754        .map_err(|e| {
1755            BenchError::Build(format!(
1756                "Failed to run codesign: {}. Ensure Xcode command line tools are installed.",
1757                e
1758            ))
1759        })?;
1760    if !output.status.success() {
1761        let stderr = String::from_utf8_lossy(&output.stderr);
1762        return Err(BenchError::Build(format!(
1763            "codesign failed: {}. Verify you have a valid signing identity.",
1764            stderr
1765        )));
1766    }
1767    Ok(())
1768}
1769
1770fn pascalize_first(value: &str) -> String {
1771    let mut chars = value.chars();
1772    match chars.next() {
1773        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
1774        None => String::new(),
1775    }
1776}
1777
1778fn boltffi_swift_bindings_filename(crate_name: &str) -> String {
1779    format!("{}BoltFFI.swift", pascalize_first(crate_name))
1780}
1781
1782fn boltffi_swift_bindings_path_fragment(crate_name: &str) -> PathBuf {
1783    PathBuf::from("BoltFFI").join(boltffi_swift_bindings_filename(crate_name))
1784}
1785
1786/// iOS code signing methods for IPA packaging
1787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1788pub enum SigningMethod {
1789    /// Ad-hoc signing (no Apple ID required, works for BrowserStack testing)
1790    AdHoc,
1791    /// Development signing (requires Apple Developer account and provisioning profile)
1792    Development,
1793}
1794
1795impl IosBuilder {
1796    /// Packages the iOS app as an IPA file for distribution or testing
1797    ///
1798    /// This requires the app to have been built first with `build()`.
1799    /// The IPA can be used for:
1800    /// - BrowserStack device testing (ad-hoc signing)
1801    /// - Physical device testing (development signing)
1802    ///
1803    /// # Arguments
1804    ///
1805    /// * `scheme` - The Xcode scheme to build (e.g., "BenchRunner")
1806    /// * `method` - The signing method (AdHoc or Development)
1807    ///
1808    /// # Returns
1809    ///
1810    /// * `Ok(PathBuf)` - Path to the generated IPA file
1811    /// * `Err(BenchError)` - If the build or packaging fails
1812    ///
1813    /// # Example
1814    ///
1815    /// ```no_run
1816    /// use mobench_sdk::builders::{IosBuilder, SigningMethod};
1817    ///
1818    /// let builder = IosBuilder::new(".", "bench-mobile");
1819    /// let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
1820    /// println!("IPA created at: {:?}", ipa_path);
1821    /// # Ok::<(), mobench_sdk::BenchError>(())
1822    /// ```
1823    pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
1824        // For repository structure: ios/BenchRunner/BenchRunner.xcodeproj
1825        // The directory and scheme happen to have the same name
1826        let ios_dir = self.output_dir.join("ios").join(scheme);
1827        let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
1828
1829        // Verify Xcode project exists
1830        if !project_path.exists() {
1831            return Err(BenchError::Build(format!(
1832                "Xcode project not found at {}.\n\n\
1833                 Run `cargo mobench build --target ios` first or check --output-dir.",
1834                project_path.display()
1835            )));
1836        }
1837
1838        let export_path = self.output_dir.join("ios");
1839        let ipa_path = export_path.join(format!("{}.ipa", scheme));
1840
1841        // Create target/ios directory if it doesn't exist
1842        fs::create_dir_all(&export_path).map_err(|e| {
1843            BenchError::Build(format!(
1844                "Failed to create export directory at {}: {}. Check output directory permissions.",
1845                export_path.display(),
1846                e
1847            ))
1848        })?;
1849
1850        println!("Building {} for device...", scheme);
1851
1852        // Step 1: Build the app for device (simpler than archiving)
1853        let build_dir = self.output_dir.join("ios/build");
1854        // Package the same optimized device binary we ship to BrowserStack.
1855        // `Release + iphoneos` has proven more stable in CI than the previous
1856        // implicit Debug destination build.
1857        let build_configuration = "Release";
1858        let mut cmd = Command::new("xcodebuild");
1859        cmd.arg("-project")
1860            .arg(&project_path)
1861            .arg("-scheme")
1862            .arg(scheme)
1863            .arg("-destination")
1864            .arg("generic/platform=iOS")
1865            .arg("-sdk")
1866            .arg("iphoneos")
1867            .arg("-configuration")
1868            .arg(build_configuration)
1869            .arg("-derivedDataPath")
1870            .arg(&build_dir)
1871            .arg("build");
1872
1873        // Add signing parameters based on method
1874        match method {
1875            SigningMethod::AdHoc => {
1876                // Ad-hoc packaging on CI needs the app target to skip both signing
1877                // and product validation; otherwise Xcode exits 65 after emitting a
1878                // partial .app bundle with no executable.
1879                cmd.args([
1880                    "VALIDATE_PRODUCT=NO",
1881                    "CODE_SIGN_STYLE=Manual",
1882                    "CODE_SIGN_IDENTITY=",
1883                    "CODE_SIGNING_ALLOWED=NO",
1884                    "CODE_SIGNING_REQUIRED=NO",
1885                    "DEVELOPMENT_TEAM=",
1886                    "PROVISIONING_PROFILE_SPECIFIER=",
1887                ]);
1888            }
1889            SigningMethod::Development => {
1890                // Development signing (requires Apple Developer account)
1891                cmd.args([
1892                    "CODE_SIGN_STYLE=Automatic",
1893                    "CODE_SIGN_IDENTITY=iPhone Developer",
1894                ]);
1895            }
1896        }
1897
1898        if self.verbose {
1899            println!("  Running: {:?}", cmd);
1900        }
1901
1902        // Run the build - may fail on validation but still produce the .app
1903        let build_result = cmd.output();
1904
1905        // Step 2: Check if the .app bundle was created (even if validation failed)
1906        let app_path = build_dir
1907            .join(format!("Build/Products/{}-iphoneos", build_configuration))
1908            .join(format!("{}.app", scheme));
1909
1910        if !app_path.exists() {
1911            match build_result {
1912                Ok(output) => {
1913                    let stdout = String::from_utf8_lossy(&output.stdout);
1914                    let stderr = String::from_utf8_lossy(&output.stderr);
1915                    return Err(BenchError::Build(format!(
1916                        "xcodebuild build failed and app bundle was not created.\n\n\
1917                         Project: {}\n\
1918                         Scheme: {}\n\
1919                         Configuration: {}\n\
1920                         Derived data: {}\n\
1921                         Exit status: {}\n\n\
1922                         Stdout:\n{}\n\n\
1923                         Stderr:\n{}\n\n\
1924                         Tip: run xcodebuild manually to inspect the failure.",
1925                        project_path.display(),
1926                        scheme,
1927                        build_configuration,
1928                        build_dir.display(),
1929                        output.status,
1930                        stdout,
1931                        stderr
1932                    )));
1933                }
1934                Err(err) => {
1935                    return Err(BenchError::Build(format!(
1936                        "Failed to run xcodebuild: {}.\n\n\
1937                         App bundle not found at {}.\n\
1938                         Check that Xcode command line tools are installed.",
1939                        err,
1940                        app_path.display()
1941                    )));
1942                }
1943            }
1944        }
1945
1946        if self.verbose {
1947            println!("  App bundle created successfully at {:?}", app_path);
1948        }
1949
1950        let build_log_path = export_path.join("ipa-build.log");
1951        if let Ok(output) = &build_result
1952            && !output.status.success()
1953        {
1954            let mut log = String::new();
1955            log.push_str("STDOUT:\n");
1956            log.push_str(&String::from_utf8_lossy(&output.stdout));
1957            log.push_str("\n\nSTDERR:\n");
1958            log.push_str(&String::from_utf8_lossy(&output.stderr));
1959            let _ = fs::write(&build_log_path, log);
1960            println!(
1961                "Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing. Log: {}",
1962                output.status,
1963                app_path.display(),
1964                build_log_path.display()
1965            );
1966        }
1967
1968        let source_info_plist = ios_dir.join(scheme).join("Info.plist");
1969        if let Err(bundle_err) =
1970            self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme)
1971        {
1972            if let Ok(output) = &build_result
1973                && !output.status.success()
1974            {
1975                let stdout = String::from_utf8_lossy(&output.stdout);
1976                let stderr = String::from_utf8_lossy(&output.stderr);
1977                return Err(BenchError::Build(format!(
1978                    "xcodebuild build produced an incomplete app bundle.\n\n\
1979                     Project: {}\n\
1980                     Scheme: {}\n\
1981                     Configuration: {}\n\
1982                     Derived data: {}\n\
1983                     Exit status: {}\n\
1984                     Log: {}\n\n\
1985                     Bundle validation: {}\n\n\
1986                     Stdout:\n{}\n\n\
1987                     Stderr:\n{}",
1988                    project_path.display(),
1989                    scheme,
1990                    build_configuration,
1991                    build_dir.display(),
1992                    output.status,
1993                    build_log_path.display(),
1994                    bundle_err,
1995                    stdout,
1996                    stderr
1997                )));
1998            }
1999            return Err(bundle_err);
2000        }
2001
2002        if matches!(method, SigningMethod::AdHoc) {
2003            let profile = find_provisioning_profile();
2004            let identity = find_codesign_identity();
2005            match (profile.as_ref(), identity.as_ref()) {
2006                (Some(profile), Some(identity)) => {
2007                    embed_provisioning_profile(&app_path, profile)?;
2008                    codesign_bundle(&app_path, identity)?;
2009                    if self.verbose {
2010                        println!("  Signed app bundle with identity {}", identity);
2011                    }
2012                }
2013                _ => {
2014                    let output = Command::new("codesign")
2015                        .arg("--force")
2016                        .arg("--deep")
2017                        .arg("--sign")
2018                        .arg("-")
2019                        .arg(&app_path)
2020                        .output();
2021                    match output {
2022                        Ok(output) if output.status.success() => {
2023                            println!(
2024                                "Warning: Signed app bundle without provisioning profile; BrowserStack install may fail."
2025                            );
2026                        }
2027                        Ok(output) => {
2028                            let stderr = String::from_utf8_lossy(&output.stderr);
2029                            println!("Warning: Ad-hoc signing failed: {}", stderr);
2030                        }
2031                        Err(err) => {
2032                            println!("Warning: Could not run codesign: {}", err);
2033                        }
2034                    }
2035                }
2036            }
2037        }
2038
2039        println!("Creating IPA from app bundle...");
2040
2041        // Step 3: Stage the app bundle inside Payload/ and archive it with
2042        // `ditto`, which preserves the bundle structure the way Xcode-generated
2043        // IPAs do. The earlier recursive copy + `zip` path produced invalid
2044        // BrowserStack uploads in CI.
2045        let payload_dir = export_path.join("Payload");
2046        if payload_dir.exists() {
2047            fs::remove_dir_all(&payload_dir).map_err(|e| {
2048                BenchError::Build(format!(
2049                    "Failed to remove old Payload dir at {}: {}. Close any tools using it and retry.",
2050                    payload_dir.display(),
2051                    e
2052                ))
2053            })?;
2054        }
2055        fs::create_dir_all(&payload_dir).map_err(|e| {
2056            BenchError::Build(format!(
2057                "Failed to create Payload dir at {}: {}. Check output directory permissions.",
2058                payload_dir.display(),
2059                e
2060            ))
2061        })?;
2062
2063        // Copy app bundle into Payload/ using the standard macOS bundle copier.
2064        let dest_app = payload_dir.join(format!("{}.app", scheme));
2065        self.copy_bundle_with_ditto(&app_path, &dest_app)?;
2066
2067        // Create IPA archive
2068        if ipa_path.exists() {
2069            fs::remove_file(&ipa_path).map_err(|e| {
2070                BenchError::Build(format!(
2071                    "Failed to remove old IPA at {}: {}. Check file permissions.",
2072                    ipa_path.display(),
2073                    e
2074                ))
2075            })?;
2076        }
2077
2078        let mut cmd = Command::new("ditto");
2079        cmd.arg("-c")
2080            .arg("-k")
2081            .arg("--sequesterRsrc")
2082            .arg("--keepParent")
2083            .arg("Payload")
2084            .arg(&ipa_path)
2085            .current_dir(&export_path);
2086
2087        if self.verbose {
2088            println!("  Running: {:?}", cmd);
2089        }
2090
2091        run_command(cmd, "create IPA archive with ditto")?;
2092        self.validate_ipa_archive(&ipa_path, scheme)?;
2093
2094        // Clean up Payload directory
2095        fs::remove_dir_all(&payload_dir).map_err(|e| {
2096            BenchError::Build(format!(
2097                "Failed to clean up Payload dir at {}: {}. Check file permissions.",
2098                payload_dir.display(),
2099                e
2100            ))
2101        })?;
2102
2103        println!("✓ IPA created: {:?}", ipa_path);
2104        Ok(ipa_path)
2105    }
2106
2107    /// Packages the XCUITest runner app into a zip for BrowserStack.
2108    ///
2109    /// This requires the app project to be generated first with `build()`.
2110    /// The resulting zip can be supplied to BrowserStack as the test suite.
2111    pub fn package_xcuitest(&self, scheme: &str) -> Result<PathBuf, BenchError> {
2112        let ios_dir = self.output_dir.join("ios").join(scheme);
2113        let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
2114
2115        if !project_path.exists() {
2116            return Err(BenchError::Build(format!(
2117                "Xcode project not found at {}.\n\n\
2118                 Run `cargo mobench build --target ios` first or check --output-dir.",
2119                project_path.display()
2120            )));
2121        }
2122
2123        let export_path = self.output_dir.join("ios");
2124        fs::create_dir_all(&export_path).map_err(|e| {
2125            BenchError::Build(format!(
2126                "Failed to create export directory at {}: {}. Check output directory permissions.",
2127                export_path.display(),
2128                e
2129            ))
2130        })?;
2131
2132        let build_dir = self.output_dir.join("ios/build");
2133        println!("Building XCUITest runner for {}...", scheme);
2134
2135        let mut cmd = Command::new("xcodebuild");
2136        cmd.arg("build-for-testing")
2137            .arg("-project")
2138            .arg(&project_path)
2139            .arg("-scheme")
2140            .arg(scheme)
2141            .arg("-destination")
2142            .arg("generic/platform=iOS")
2143            .arg("-sdk")
2144            .arg("iphoneos")
2145            .arg("-configuration")
2146            .arg("Release")
2147            .arg("-derivedDataPath")
2148            .arg(&build_dir)
2149            .arg("VALIDATE_PRODUCT=NO")
2150            .arg("CODE_SIGN_STYLE=Manual")
2151            .arg("CODE_SIGN_IDENTITY=")
2152            .arg("CODE_SIGNING_ALLOWED=NO")
2153            .arg("CODE_SIGNING_REQUIRED=NO")
2154            .arg("DEVELOPMENT_TEAM=")
2155            .arg("PROVISIONING_PROFILE_SPECIFIER=")
2156            .arg("ENABLE_BITCODE=NO")
2157            .arg("BITCODE_GENERATION_MODE=none")
2158            .arg("STRIP_BITCODE_FROM_COPIED_FILES=NO");
2159
2160        if self.verbose {
2161            println!("  Running: {:?}", cmd);
2162        }
2163
2164        let runner_name = format!("{}UITests-Runner.app", scheme);
2165        let runner_path = build_dir
2166            .join("Build/Products/Release-iphoneos")
2167            .join(&runner_name);
2168
2169        let build_result = cmd.output();
2170        let log_path = export_path.join("xcuitest-build.log");
2171        if let Ok(output) = &build_result
2172            && !output.status.success()
2173        {
2174            let mut log = String::new();
2175            let stdout = String::from_utf8_lossy(&output.stdout);
2176            let stderr = String::from_utf8_lossy(&output.stderr);
2177            log.push_str("STDOUT:\n");
2178            log.push_str(&stdout);
2179            log.push_str("\n\nSTDERR:\n");
2180            log.push_str(&stderr);
2181            let _ = fs::write(&log_path, log);
2182            println!("xcodebuild log written to {:?}", log_path);
2183            if runner_path.exists() {
2184                println!(
2185                    "Warning: xcodebuild build-for-testing failed, but runner exists: {}",
2186                    stderr
2187                );
2188            }
2189        }
2190
2191        if !runner_path.exists() {
2192            match build_result {
2193                Ok(output) => {
2194                    let stdout = String::from_utf8_lossy(&output.stdout);
2195                    let stderr = String::from_utf8_lossy(&output.stderr);
2196                    return Err(BenchError::Build(format!(
2197                        "xcodebuild build-for-testing failed and runner was not created.\n\n\
2198                         Project: {}\n\
2199                         Scheme: {}\n\
2200                         Derived data: {}\n\
2201                         Exit status: {}\n\
2202                         Log: {}\n\n\
2203                         Stdout:\n{}\n\n\
2204                         Stderr:\n{}\n\n\
2205                         Tip: open the log file above for more context.",
2206                        project_path.display(),
2207                        scheme,
2208                        build_dir.display(),
2209                        output.status,
2210                        log_path.display(),
2211                        stdout,
2212                        stderr
2213                    )));
2214                }
2215                Err(err) => {
2216                    return Err(BenchError::Build(format!(
2217                        "Failed to run xcodebuild: {}.\n\n\
2218                         XCUITest runner not found at {}.\n\
2219                         Check that Xcode command line tools are installed.",
2220                        err,
2221                        runner_path.display()
2222                    )));
2223                }
2224            }
2225        }
2226
2227        let profile = find_provisioning_profile();
2228        let identity = find_codesign_identity();
2229        if let (Some(profile), Some(identity)) = (profile.as_ref(), identity.as_ref()) {
2230            embed_provisioning_profile(&runner_path, profile)?;
2231            codesign_bundle(&runner_path, identity)?;
2232            if self.verbose {
2233                println!("  Signed XCUITest runner with identity {}", identity);
2234            }
2235        } else {
2236            println!(
2237                "Warning: No provisioning profile/identity found; XCUITest runner may not install."
2238            );
2239        }
2240
2241        let zip_path = export_path.join(format!("{}UITests.zip", scheme));
2242        if zip_path.exists() {
2243            fs::remove_file(&zip_path).map_err(|e| {
2244                BenchError::Build(format!(
2245                    "Failed to remove old zip at {}: {}. Check file permissions.",
2246                    zip_path.display(),
2247                    e
2248                ))
2249            })?;
2250        }
2251
2252        let runner_parent = runner_path.parent().ok_or_else(|| {
2253            BenchError::Build(format!(
2254                "Invalid XCUITest runner path with no parent directory: {}",
2255                runner_path.display()
2256            ))
2257        })?;
2258
2259        let mut zip_cmd = Command::new("zip");
2260        zip_cmd
2261            .arg("-qr")
2262            .arg(&zip_path)
2263            .arg(&runner_name)
2264            .current_dir(runner_parent);
2265
2266        if self.verbose {
2267            println!("  Running: {:?}", zip_cmd);
2268        }
2269
2270        run_command(zip_cmd, "zip XCUITest runner")?;
2271        println!("✓ XCUITest runner packaged: {:?}", zip_path);
2272
2273        Ok(zip_path)
2274    }
2275
2276    fn copy_bundle_with_ditto(&self, src: &Path, dest: &Path) -> Result<(), BenchError> {
2277        let mut cmd = Command::new("ditto");
2278        cmd.arg(src).arg(dest);
2279
2280        if self.verbose {
2281            println!("  Running: {:?}", cmd);
2282        }
2283
2284        run_command(cmd, "copy app bundle with ditto")
2285    }
2286
2287    fn ensure_device_app_bundle_metadata(
2288        &self,
2289        app_path: &Path,
2290        source_info_plist: &Path,
2291        scheme: &str,
2292    ) -> Result<(), BenchError> {
2293        let bundled_info_plist = app_path.join("Info.plist");
2294        if !bundled_info_plist.is_file() {
2295            if !source_info_plist.is_file() {
2296                return Err(BenchError::Build(format!(
2297                    "Built app bundle at {} is missing Info.plist, and the generated source plist was not found at {}.\n\n\
2298                     The device build produced an incomplete .app bundle, so packaging cannot continue.",
2299                    app_path.display(),
2300                    source_info_plist.display()
2301                )));
2302            }
2303
2304            fs::copy(source_info_plist, &bundled_info_plist).map_err(|e| {
2305                BenchError::Build(format!(
2306                    "Built app bundle at {} is missing Info.plist, and restoring it from {} failed: {}.",
2307                    app_path.display(),
2308                    source_info_plist.display(),
2309                    e
2310                ))
2311            })?;
2312            println!(
2313                "Warning: Restored missing Info.plist into built app bundle from {}.",
2314                source_info_plist.display()
2315            );
2316        }
2317
2318        let executable = app_path.join(scheme);
2319        if !executable.is_file() {
2320            return Err(BenchError::Build(format!(
2321                "Built app bundle at {} is missing the expected executable {}.\n\n\
2322                 The device build produced an incomplete .app bundle, so packaging cannot continue.",
2323                app_path.display(),
2324                executable.display()
2325            )));
2326        }
2327
2328        Ok(())
2329    }
2330
2331    fn validate_ipa_archive(&self, ipa_path: &Path, scheme: &str) -> Result<(), BenchError> {
2332        let extract_root = env::temp_dir().join(format!(
2333            "mobench-ipa-validate-{}-{}",
2334            std::process::id(),
2335            SystemTime::now()
2336                .duration_since(UNIX_EPOCH)
2337                .map(|d| d.as_nanos())
2338                .unwrap_or(0)
2339        ));
2340
2341        if extract_root.exists() {
2342            fs::remove_dir_all(&extract_root).map_err(|e| {
2343                BenchError::Build(format!(
2344                    "Failed to clear IPA validation dir at {}: {}",
2345                    extract_root.display(),
2346                    e
2347                ))
2348            })?;
2349        }
2350        fs::create_dir_all(&extract_root).map_err(|e| {
2351            BenchError::Build(format!(
2352                "Failed to create IPA validation dir at {}: {}",
2353                extract_root.display(),
2354                e
2355            ))
2356        })?;
2357
2358        let mut extract = Command::new("ditto");
2359        extract.arg("-x").arg("-k").arg(ipa_path).arg(&extract_root);
2360
2361        let extract_result = run_command(extract, "extract IPA for validation");
2362        if let Err(err) = extract_result {
2363            let _ = fs::remove_dir_all(&extract_root);
2364            return Err(err);
2365        }
2366
2367        let info_plist = extract_root
2368            .join("Payload")
2369            .join(format!("{}.app", scheme))
2370            .join("Info.plist");
2371        let validation_result = if info_plist.is_file() {
2372            Ok(())
2373        } else {
2374            Err(BenchError::Build(format!(
2375                "IPA validation failed: {} is missing from {}.\n\n\
2376                 The packaged IPA does not contain a valid iOS app bundle. \
2377                 BrowserStack will reject this upload.",
2378                info_plist
2379                    .strip_prefix(&extract_root)
2380                    .unwrap_or(&info_plist)
2381                    .display(),
2382                ipa_path.display()
2383            )))
2384        };
2385
2386        let _ = fs::remove_dir_all(&extract_root);
2387        validation_result
2388    }
2389}
2390
2391#[cfg(test)]
2392mod tests {
2393    use super::*;
2394    #[cfg(target_os = "macos")]
2395    use std::io::Write;
2396
2397    #[test]
2398    fn test_ios_builder_creation() {
2399        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2400        assert!(!builder.verbose);
2401        assert_eq!(
2402            builder.output_dir,
2403            PathBuf::from("/tmp/test-project/target/mobench")
2404        );
2405    }
2406
2407    #[test]
2408    fn test_ios_builder_verbose() {
2409        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
2410        assert!(builder.verbose);
2411    }
2412
2413    #[test]
2414    fn test_ios_builder_custom_output_dir() {
2415        let builder =
2416            IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output");
2417        assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
2418    }
2419
2420    #[test]
2421    fn test_boltffi_swift_bindings_filename_uses_crate_name() {
2422        assert_eq!(
2423            boltffi_swift_bindings_filename("ffi-benchmark"),
2424            "Ffi-benchmarkBoltFFI.swift"
2425        );
2426        assert_eq!(
2427            boltffi_swift_bindings_filename("sample_fns"),
2428            "Sample_fnsBoltFFI.swift"
2429        );
2430        assert_eq!(
2431            boltffi_swift_bindings_path_fragment("ffi-benchmark"),
2432            PathBuf::from("BoltFFI/Ffi-benchmarkBoltFFI.swift")
2433        );
2434    }
2435
2436    #[cfg(target_os = "macos")]
2437    #[test]
2438    fn test_validate_ipa_archive_rejects_missing_info_plist() {
2439        let temp_dir = env::temp_dir().join(format!(
2440            "mobench-ios-test-bad-ipa-{}-{}",
2441            std::process::id(),
2442            SystemTime::now()
2443                .duration_since(UNIX_EPOCH)
2444                .map(|d| d.as_nanos())
2445                .unwrap_or(0)
2446        ));
2447        let payload = temp_dir.join("Payload/BenchRunner.app");
2448        fs::create_dir_all(&payload).expect("create payload");
2449        let ipa = temp_dir.join("broken.ipa");
2450
2451        let status = Command::new("ditto")
2452            .arg("-c")
2453            .arg("-k")
2454            .arg("--sequesterRsrc")
2455            .arg("--keepParent")
2456            .arg("Payload")
2457            .arg(&ipa)
2458            .current_dir(&temp_dir)
2459            .status()
2460            .expect("run ditto");
2461        assert!(status.success(), "ditto should create the broken test ipa");
2462
2463        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2464        let err = builder
2465            .validate_ipa_archive(&ipa, "BenchRunner")
2466            .expect_err("IPA missing Info.plist should be rejected");
2467        assert!(
2468            err.to_string().contains("Info.plist"),
2469            "expected validation error mentioning Info.plist, got: {err}"
2470        );
2471
2472        let _ = fs::remove_dir_all(&temp_dir);
2473    }
2474
2475    #[cfg(target_os = "macos")]
2476    #[test]
2477    fn test_validate_ipa_archive_accepts_payload_with_info_plist() {
2478        let temp_dir = env::temp_dir().join(format!(
2479            "mobench-ios-test-good-ipa-{}-{}",
2480            std::process::id(),
2481            SystemTime::now()
2482                .duration_since(UNIX_EPOCH)
2483                .map(|d| d.as_nanos())
2484                .unwrap_or(0)
2485        ));
2486        let payload = temp_dir.join("Payload/BenchRunner.app");
2487        fs::create_dir_all(&payload).expect("create payload");
2488        let mut info = fs::File::create(payload.join("Info.plist")).expect("create plist");
2489        writeln!(
2490            info,
2491            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>"
2492        )
2493        .expect("write plist");
2494        let ipa = temp_dir.join("valid.ipa");
2495
2496        let status = Command::new("ditto")
2497            .arg("-c")
2498            .arg("-k")
2499            .arg("--sequesterRsrc")
2500            .arg("--keepParent")
2501            .arg("Payload")
2502            .arg(&ipa)
2503            .current_dir(&temp_dir)
2504            .status()
2505            .expect("run ditto");
2506        assert!(status.success(), "ditto should create the valid test ipa");
2507
2508        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2509        builder
2510            .validate_ipa_archive(&ipa, "BenchRunner")
2511            .expect("IPA with Info.plist should validate");
2512
2513        let _ = fs::remove_dir_all(&temp_dir);
2514    }
2515
2516    #[test]
2517    fn test_ensure_device_app_bundle_metadata_restores_missing_info_plist() {
2518        let temp_dir = env::temp_dir().join(format!(
2519            "mobench-ios-test-repair-plist-{}-{}",
2520            std::process::id(),
2521            SystemTime::now()
2522                .duration_since(UNIX_EPOCH)
2523                .map(|d| d.as_nanos())
2524                .unwrap_or(0)
2525        ));
2526        let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
2527        fs::create_dir_all(&app_dir).expect("create app dir");
2528        fs::write(app_dir.join("BenchRunner"), "bin").expect("create executable");
2529
2530        let source_dir = temp_dir.join("BenchRunner");
2531        fs::create_dir_all(&source_dir).expect("create source dir");
2532        fs::write(
2533            source_dir.join("Info.plist"),
2534            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2535        )
2536        .expect("create source plist");
2537
2538        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2539        builder
2540            .ensure_device_app_bundle_metadata(
2541                &app_dir,
2542                &source_dir.join("Info.plist"),
2543                "BenchRunner",
2544            )
2545            .expect("missing plist should be restored");
2546
2547        assert!(
2548            app_dir.join("Info.plist").is_file(),
2549            "restored app bundle should contain Info.plist"
2550        );
2551
2552        let _ = fs::remove_dir_all(&temp_dir);
2553    }
2554
2555    #[test]
2556    fn test_ensure_device_app_bundle_metadata_rejects_missing_executable() {
2557        let temp_dir = env::temp_dir().join(format!(
2558            "mobench-ios-test-missing-exec-{}-{}",
2559            std::process::id(),
2560            SystemTime::now()
2561                .duration_since(UNIX_EPOCH)
2562                .map(|d| d.as_nanos())
2563                .unwrap_or(0)
2564        ));
2565        let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
2566        fs::create_dir_all(&app_dir).expect("create app dir");
2567        fs::write(
2568            app_dir.join("Info.plist"),
2569            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2570        )
2571        .expect("create bundled plist");
2572        let source_dir = temp_dir.join("BenchRunner");
2573        fs::create_dir_all(&source_dir).expect("create source dir");
2574        fs::write(
2575            source_dir.join("Info.plist"),
2576            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2577        )
2578        .expect("create source plist");
2579
2580        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2581        let err = builder
2582            .ensure_device_app_bundle_metadata(
2583                &app_dir,
2584                &source_dir.join("Info.plist"),
2585                "BenchRunner",
2586            )
2587            .expect_err("missing executable should fail validation");
2588        assert!(
2589            err.to_string().contains("missing the expected executable"),
2590            "expected executable validation error, got: {err}"
2591        );
2592
2593        let _ = fs::remove_dir_all(&temp_dir);
2594    }
2595
2596    #[test]
2597    fn test_find_crate_dir_current_directory_is_crate() {
2598        // Test case 1: Current directory IS the crate with matching package name
2599        let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-current");
2600        let _ = std::fs::remove_dir_all(&temp_dir);
2601        std::fs::create_dir_all(&temp_dir).unwrap();
2602
2603        // Create Cargo.toml with matching package name
2604        std::fs::write(
2605            temp_dir.join("Cargo.toml"),
2606            r#"[package]
2607name = "bench-mobile"
2608version = "0.1.0"
2609"#,
2610        )
2611        .unwrap();
2612
2613        let builder = IosBuilder::new(&temp_dir, "bench-mobile");
2614        let result = builder.find_crate_dir();
2615        assert!(result.is_ok(), "Should find crate in current directory");
2616        // Note: IosBuilder canonicalizes paths, so compare canonical forms
2617        let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone());
2618        assert_eq!(result.unwrap(), expected);
2619
2620        std::fs::remove_dir_all(&temp_dir).unwrap();
2621    }
2622
2623    #[test]
2624    fn test_find_crate_dir_nested_bench_mobile() {
2625        // Test case 2: Crate is in bench-mobile/ subdirectory
2626        let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-nested");
2627        let _ = std::fs::remove_dir_all(&temp_dir);
2628        std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
2629
2630        // Create parent Cargo.toml (workspace or different crate)
2631        std::fs::write(
2632            temp_dir.join("Cargo.toml"),
2633            r#"[workspace]
2634members = ["bench-mobile"]
2635"#,
2636        )
2637        .unwrap();
2638
2639        // Create bench-mobile/Cargo.toml
2640        std::fs::write(
2641            temp_dir.join("bench-mobile/Cargo.toml"),
2642            r#"[package]
2643name = "bench-mobile"
2644version = "0.1.0"
2645"#,
2646        )
2647        .unwrap();
2648
2649        let builder = IosBuilder::new(&temp_dir, "bench-mobile");
2650        let result = builder.find_crate_dir();
2651        assert!(
2652            result.is_ok(),
2653            "Should find crate in bench-mobile/ directory"
2654        );
2655        let expected = temp_dir
2656            .canonicalize()
2657            .unwrap_or(temp_dir.clone())
2658            .join("bench-mobile");
2659        assert_eq!(result.unwrap(), expected);
2660
2661        std::fs::remove_dir_all(&temp_dir).unwrap();
2662    }
2663
2664    #[test]
2665    fn test_find_crate_dir_crates_subdir() {
2666        // Test case 3: Crate is in crates/{name}/ subdirectory
2667        let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-crates");
2668        let _ = std::fs::remove_dir_all(&temp_dir);
2669        std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
2670
2671        // Create workspace Cargo.toml
2672        std::fs::write(
2673            temp_dir.join("Cargo.toml"),
2674            r#"[workspace]
2675members = ["crates/*"]
2676"#,
2677        )
2678        .unwrap();
2679
2680        // Create crates/my-bench/Cargo.toml
2681        std::fs::write(
2682            temp_dir.join("crates/my-bench/Cargo.toml"),
2683            r#"[package]
2684name = "my-bench"
2685version = "0.1.0"
2686"#,
2687        )
2688        .unwrap();
2689
2690        let builder = IosBuilder::new(&temp_dir, "my-bench");
2691        let result = builder.find_crate_dir();
2692        assert!(result.is_ok(), "Should find crate in crates/ directory");
2693        let expected = temp_dir
2694            .canonicalize()
2695            .unwrap_or(temp_dir.clone())
2696            .join("crates/my-bench");
2697        assert_eq!(result.unwrap(), expected);
2698
2699        std::fs::remove_dir_all(&temp_dir).unwrap();
2700    }
2701
2702    #[test]
2703    fn test_find_crate_dir_not_found() {
2704        // Test case 4: Crate doesn't exist anywhere
2705        let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-notfound");
2706        let _ = std::fs::remove_dir_all(&temp_dir);
2707        std::fs::create_dir_all(&temp_dir).unwrap();
2708
2709        // Create Cargo.toml with DIFFERENT package name
2710        std::fs::write(
2711            temp_dir.join("Cargo.toml"),
2712            r#"[package]
2713name = "some-other-crate"
2714version = "0.1.0"
2715"#,
2716        )
2717        .unwrap();
2718
2719        let builder = IosBuilder::new(&temp_dir, "nonexistent-crate");
2720        let result = builder.find_crate_dir();
2721        assert!(result.is_err(), "Should fail to find nonexistent crate");
2722        let err_msg = result.unwrap_err().to_string();
2723        assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
2724        assert!(err_msg.contains("Searched locations"));
2725
2726        std::fs::remove_dir_all(&temp_dir).unwrap();
2727    }
2728
2729    #[test]
2730    fn test_find_crate_dir_explicit_crate_path() {
2731        // Test case 5: Explicit crate_dir overrides auto-detection
2732        let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-explicit");
2733        let _ = std::fs::remove_dir_all(&temp_dir);
2734        std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
2735
2736        let builder =
2737            IosBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
2738        let result = builder.find_crate_dir();
2739        assert!(result.is_ok(), "Should use explicit crate_dir");
2740        assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
2741
2742        std::fs::remove_dir_all(&temp_dir).unwrap();
2743    }
2744}