Skip to main content

mobench_sdk/builders/
ios.rs

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