Skip to main content

mobench_sdk/builders/
ios.rs

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