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