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