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