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