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