mobench_sdk/builders/
ios.rs

1//! iOS build automation
2//!
3//! This module provides functionality to build Rust libraries for iOS and
4//! create an xcframework that can be used in Xcode projects.
5
6use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
7use std::env;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13/// iOS builder that handles the complete build pipeline
14pub struct IosBuilder {
15    /// Root directory of the project
16    project_root: PathBuf,
17    /// Name of the bench-mobile crate
18    crate_name: String,
19    /// Whether to use verbose output
20    verbose: bool,
21}
22
23impl IosBuilder {
24    /// Creates a new iOS builder
25    ///
26    /// # Arguments
27    ///
28    /// * `project_root` - Root directory containing the bench-mobile crate
29    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
30    pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
31        Self {
32            project_root: project_root.into(),
33            crate_name: crate_name.into(),
34            verbose: false,
35        }
36    }
37
38    /// Enables verbose output
39    pub fn verbose(mut self, verbose: bool) -> Self {
40        self.verbose = verbose;
41        self
42    }
43
44    /// Builds the iOS app with the given configuration
45    ///
46    /// This performs the following steps:
47    /// 1. Build Rust libraries for iOS targets (device + simulator)
48    /// 2. Generate UniFFI Swift bindings and C headers
49    /// 3. Create xcframework with proper structure
50    /// 4. Code-sign the xcframework
51    /// 5. Generate Xcode project with xcodegen (if project.yml exists)
52    ///
53    /// # Returns
54    ///
55    /// * `Ok(BuildResult)` containing the path to the xcframework
56    /// * `Err(BenchError)` if the build fails
57    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
58        let framework_name = self.crate_name.replace("-", "_");
59        // Step 1: Build Rust libraries
60        println!("Building Rust libraries for iOS...");
61        self.build_rust_libraries(config)?;
62
63        // Step 2: Generate UniFFI bindings
64        println!("Generating UniFFI Swift bindings...");
65        self.generate_uniffi_bindings()?;
66
67        // Step 3: Create xcframework
68        println!("Creating xcframework...");
69        let xcframework_path = self.create_xcframework(config)?;
70
71        // Step 4: Code-sign xcframework
72        println!("Code-signing xcframework...");
73        self.codesign_xcframework(&xcframework_path)?;
74
75        // Copy header to include/ for consumers (handy for CLI uploads)
76        let header_src = self
77            .find_uniffi_header(&format!("{}FFI.h", framework_name))
78            .ok_or_else(|| {
79                BenchError::Build(format!(
80                    "UniFFI header {}FFI.h not found after generation",
81                    framework_name
82                ))
83            })?;
84        let include_dir = self.project_root.join("target/ios/include");
85        fs::create_dir_all(&include_dir)
86            .map_err(|e| BenchError::Build(format!("Failed to create include dir: {}", e)))?;
87        let header_dest = include_dir.join(format!("{}.h", framework_name));
88        fs::copy(&header_src, &header_dest).map_err(|e| {
89            BenchError::Build(format!(
90                "Failed to copy UniFFI header to {:?}: {}",
91                header_dest, e
92            ))
93        })?;
94
95        // Step 5: Generate Xcode project if needed
96        self.generate_xcode_project()?;
97
98        Ok(BuildResult {
99            platform: Target::Ios,
100            app_path: xcframework_path,
101            test_suite_path: None,
102        })
103    }
104
105    /// Builds Rust libraries for iOS targets
106    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
107        let bench_mobile_dir = self.project_root.join("bench-mobile");
108
109        if !bench_mobile_dir.exists() {
110            return Err(BenchError::Build(format!(
111                "bench-mobile crate not found at {:?}",
112                bench_mobile_dir
113            )));
114        }
115
116        // iOS targets: device and simulator
117        let targets = vec![
118            "aarch64-apple-ios",     // Device (ARM64)
119            "aarch64-apple-ios-sim", // Simulator (M1+ Macs)
120        ];
121
122        // Check if targets are installed
123        self.check_rust_targets(&targets)?;
124
125        for target in targets {
126            if self.verbose {
127                println!("  Building for {}", target);
128            }
129
130            let mut cmd = Command::new("cargo");
131            cmd.arg("build").arg("--target").arg(target).arg("--lib");
132
133            // Add release flag if needed
134            if matches!(config.profile, BuildProfile::Release) {
135                cmd.arg("--release");
136            }
137
138            // Set working directory
139            cmd.current_dir(&bench_mobile_dir);
140
141            // Execute build
142            let output = cmd
143                .output()
144                .map_err(|e| BenchError::Build(format!("Failed to run cargo: {}", e)))?;
145
146            if !output.status.success() {
147                let stderr = String::from_utf8_lossy(&output.stderr);
148                return Err(BenchError::Build(format!(
149                    "cargo build failed for {}: {}",
150                    target, stderr
151                )));
152            }
153        }
154
155        Ok(())
156    }
157
158    /// Checks if required Rust targets are installed
159    fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
160        let output = Command::new("rustup")
161            .arg("target")
162            .arg("list")
163            .arg("--installed")
164            .output()
165            .map_err(|e| BenchError::Build(format!("Failed to check rustup targets: {}", e)))?;
166
167        let installed = String::from_utf8_lossy(&output.stdout);
168
169        for target in targets {
170            if !installed.contains(target) {
171                return Err(BenchError::Build(format!(
172                    "Rust target {} is not installed. Install it with: rustup target add {}",
173                    target, target
174                )));
175            }
176        }
177
178        Ok(())
179    }
180
181    /// Generates UniFFI Swift bindings
182    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
183        let bench_mobile_dir = self.project_root.join("bench-mobile");
184        if !bench_mobile_dir.exists() {
185            return Err(BenchError::Build(format!(
186                "bench-mobile crate not found at {:?}",
187                bench_mobile_dir
188            )));
189        }
190
191        // Build host library to feed uniffi-bindgen
192        let mut build_cmd = Command::new("cargo");
193        build_cmd.arg("build");
194        build_cmd.current_dir(&bench_mobile_dir);
195        run_command(build_cmd, "cargo build (host)")?;
196
197        let lib_path = host_lib_path(&bench_mobile_dir, &self.crate_name)?;
198        let out_dir = self
199            .project_root
200            .join("ios")
201            .join("BenchRunner")
202            .join("BenchRunner")
203            .join("Generated");
204        fs::create_dir_all(&out_dir).map_err(|e| {
205            BenchError::Build(format!("Failed to create Swift bindings dir: {}", e))
206        })?;
207
208        let mut cmd = Command::new("uniffi-bindgen");
209        cmd.arg("generate")
210            .arg("--library")
211            .arg(&lib_path)
212            .arg("--language")
213            .arg("swift")
214            .arg("--out-dir")
215            .arg(&out_dir);
216        run_command(cmd, "uniffi-bindgen swift")?;
217
218        if self.verbose {
219            println!("  Generated UniFFI Swift bindings at {:?}", out_dir);
220        }
221
222        Ok(())
223    }
224
225    /// Creates an xcframework from the built libraries
226    fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
227        let profile_dir = match config.profile {
228            BuildProfile::Debug => "debug",
229            BuildProfile::Release => "release",
230        };
231
232        let target_dir = self.project_root.join("target");
233        let xcframework_dir = target_dir.join("ios");
234        let framework_name = &self.crate_name.replace("-", "_");
235        let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
236
237        // Remove existing xcframework if it exists
238        if xcframework_path.exists() {
239            fs::remove_dir_all(&xcframework_path).map_err(|e| {
240                BenchError::Build(format!("Failed to remove old xcframework: {}", e))
241            })?;
242        }
243
244        // Create xcframework directory
245        fs::create_dir_all(&xcframework_dir).map_err(|e| {
246            BenchError::Build(format!("Failed to create xcframework directory: {}", e))
247        })?;
248
249        // Build framework structure for each platform
250        self.create_framework_slice(
251            &target_dir.join("aarch64-apple-ios").join(profile_dir),
252            &xcframework_path.join("ios-arm64"),
253            framework_name,
254            "ios",
255        )?;
256
257        self.create_framework_slice(
258            &target_dir.join("aarch64-apple-ios-sim").join(profile_dir),
259            &xcframework_path.join("ios-simulator-arm64"),
260            framework_name,
261            "ios-simulator",
262        )?;
263
264        // Create xcframework Info.plist
265        self.create_xcframework_plist(&xcframework_path, framework_name)?;
266
267        Ok(xcframework_path)
268    }
269
270    /// Creates a framework slice for a specific platform
271    fn create_framework_slice(
272        &self,
273        lib_path: &Path,
274        output_dir: &Path,
275        framework_name: &str,
276        platform: &str,
277    ) -> Result<(), BenchError> {
278        let framework_dir = output_dir.join(format!("{}.framework", framework_name));
279        let headers_dir = framework_dir.join("Headers");
280
281        // Create directories
282        fs::create_dir_all(&headers_dir).map_err(|e| {
283            BenchError::Build(format!("Failed to create framework directories: {}", e))
284        })?;
285
286        // Copy static library
287        let src_lib = lib_path.join(format!("lib{}.a", framework_name));
288        let dest_lib = framework_dir.join(framework_name);
289
290        if !src_lib.exists() {
291            return Err(BenchError::Build(format!(
292                "Static library not found: {:?}",
293                src_lib
294            )));
295        }
296
297        fs::copy(&src_lib, &dest_lib)
298            .map_err(|e| BenchError::Build(format!("Failed to copy static library: {}", e)))?;
299
300        // Copy UniFFI-generated header into the framework
301        let header_name = format!("{}FFI.h", framework_name);
302        let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
303            BenchError::Build(format!(
304                "UniFFI header {} not found; run binding generation before building",
305                header_name
306            ))
307        })?;
308        fs::copy(&header_path, headers_dir.join(&header_name)).map_err(|e| {
309            BenchError::Build(format!(
310                "Failed to copy UniFFI header from {:?}: {}",
311                header_path, e
312            ))
313        })?;
314
315        // Create module.modulemap
316        let modulemap_content = format!(
317            "framework module {} {{\n  umbrella header \"{}FFI.h\"\n  export *\n  module * {{ export * }}\n}}",
318            framework_name, framework_name
319        );
320        fs::write(headers_dir.join("module.modulemap"), modulemap_content)
321            .map_err(|e| BenchError::Build(format!("Failed to write module.modulemap: {}", e)))?;
322
323        // Create framework Info.plist
324        self.create_framework_plist(&framework_dir, framework_name, platform)?;
325
326        Ok(())
327    }
328
329    /// Creates Info.plist for a framework slice
330    fn create_framework_plist(
331        &self,
332        framework_dir: &Path,
333        framework_name: &str,
334        platform: &str,
335    ) -> Result<(), BenchError> {
336        let plist_content = format!(
337            r#"<?xml version="1.0" encoding="UTF-8"?>
338<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
339<plist version="1.0">
340<dict>
341    <key>CFBundleExecutable</key>
342    <string>{}</string>
343    <key>CFBundleIdentifier</key>
344    <string>dev.world.{}</string>
345    <key>CFBundleInfoDictionaryVersion</key>
346    <string>6.0</string>
347    <key>CFBundleName</key>
348    <string>{}</string>
349    <key>CFBundlePackageType</key>
350    <string>FMWK</string>
351    <key>CFBundleShortVersionString</key>
352    <string>0.1.0</string>
353    <key>CFBundleVersion</key>
354    <string>1</string>
355    <key>CFBundleSupportedPlatforms</key>
356    <array>
357        <string>{}</string>
358    </array>
359</dict>
360</plist>"#,
361            framework_name,
362            framework_name,
363            framework_name,
364            if platform == "ios" {
365                "iPhoneOS"
366            } else {
367                "iPhoneSimulator"
368            }
369        );
370
371        fs::write(framework_dir.join("Info.plist"), plist_content).map_err(|e| {
372            BenchError::Build(format!("Failed to write framework Info.plist: {}", e))
373        })?;
374
375        Ok(())
376    }
377
378    /// Creates xcframework Info.plist
379    fn create_xcframework_plist(
380        &self,
381        xcframework_path: &Path,
382        framework_name: &str,
383    ) -> Result<(), BenchError> {
384        let plist_content = format!(
385            r#"<?xml version="1.0" encoding="UTF-8"?>
386<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
387<plist version="1.0">
388<dict>
389    <key>AvailableLibraries</key>
390    <array>
391        <dict>
392            <key>LibraryIdentifier</key>
393            <string>ios-arm64</string>
394            <key>LibraryPath</key>
395            <string>{}.framework</string>
396            <key>SupportedArchitectures</key>
397            <array>
398                <string>arm64</string>
399            </array>
400            <key>SupportedPlatform</key>
401            <string>ios</string>
402        </dict>
403        <dict>
404            <key>LibraryIdentifier</key>
405            <string>ios-simulator-arm64</string>
406            <key>LibraryPath</key>
407            <string>{}.framework</string>
408            <key>SupportedArchitectures</key>
409            <array>
410                <string>arm64</string>
411            </array>
412            <key>SupportedPlatform</key>
413            <string>ios</string>
414            <key>SupportedPlatformVariant</key>
415            <string>simulator</string>
416        </dict>
417    </array>
418    <key>CFBundlePackageType</key>
419    <string>XFWK</string>
420    <key>XCFrameworkFormatVersion</key>
421    <string>1.0</string>
422</dict>
423</plist>"#,
424            framework_name, framework_name
425        );
426
427        fs::write(xcframework_path.join("Info.plist"), plist_content).map_err(|e| {
428            BenchError::Build(format!("Failed to write xcframework Info.plist: {}", e))
429        })?;
430
431        Ok(())
432    }
433
434    /// Code-signs the xcframework
435    fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
436        let output = Command::new("codesign")
437            .arg("--force")
438            .arg("--deep")
439            .arg("--sign")
440            .arg("-")
441            .arg(xcframework_path)
442            .output();
443
444        match output {
445            Ok(output) if output.status.success() => {
446                if self.verbose {
447                    println!("  Successfully code-signed xcframework");
448                }
449                Ok(())
450            }
451            Ok(output) => {
452                let stderr = String::from_utf8_lossy(&output.stderr);
453                println!("Warning: Code signing failed: {}", stderr);
454                println!("You may need to manually sign the xcframework");
455                Ok(()) // Don't fail the build for signing issues
456            }
457            Err(e) => {
458                println!("Warning: Could not run codesign: {}", e);
459                println!("You may need to manually sign the xcframework");
460                Ok(()) // Don't fail the build if codesign is not available
461            }
462        }
463    }
464
465    /// Generates Xcode project using xcodegen if project.yml exists
466    fn generate_xcode_project(&self) -> Result<(), BenchError> {
467        let ios_dir = self.project_root.join("ios");
468        let project_yml = ios_dir.join("BenchRunner/project.yml");
469
470        if !project_yml.exists() {
471            if self.verbose {
472                println!("  No project.yml found, skipping xcodegen");
473            }
474            return Ok(());
475        }
476
477        if self.verbose {
478            println!("  Generating Xcode project with xcodegen");
479        }
480
481        let output = Command::new("xcodegen")
482            .arg("generate")
483            .current_dir(ios_dir.join("BenchRunner"))
484            .output();
485
486        match output {
487            Ok(output) if output.status.success() => Ok(()),
488            Ok(output) => {
489                let stderr = String::from_utf8_lossy(&output.stderr);
490                Err(BenchError::Build(format!("xcodegen failed: {}", stderr)))
491            }
492            Err(e) => {
493                println!("Warning: xcodegen not found or failed: {}", e);
494                println!("Install xcodegen with: brew install xcodegen");
495                Ok(()) // Don't fail if xcodegen is not available
496            }
497        }
498    }
499
500    /// Locate the generated UniFFI header for the crate
501    fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
502        // Check generated Swift bindings directory first
503        let swift_dir = self
504            .project_root
505            .join("ios/BenchRunner/BenchRunner/Generated");
506        let candidate_swift = swift_dir.join(header_name);
507        if candidate_swift.exists() {
508            return Some(candidate_swift);
509        }
510
511        let target_dir = self.project_root.join("target");
512        // Common UniFFI output location when using uniffi::generate_scaffolding
513        let candidate = target_dir.join("uniffi").join(header_name);
514        if candidate.exists() {
515            return Some(candidate);
516        }
517
518        // Fallback: walk the target directory for the header
519        let mut stack = vec![target_dir];
520        while let Some(dir) = stack.pop() {
521            if let Ok(entries) = fs::read_dir(&dir) {
522                for entry in entries.flatten() {
523                    let path = entry.path();
524                    if path.is_dir() {
525                        // Limit depth by skipping non-target subtrees such as incremental caches
526                        if let Some(name) = path.file_name().and_then(|n| n.to_str())
527                            && name == "incremental"
528                        {
529                            continue;
530                        }
531                        stack.push(path);
532                    } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
533                        && name == header_name
534                    {
535                        return Some(path);
536                    }
537                }
538            }
539        }
540
541        None
542    }
543}
544
545// Shared helpers (duplicated with android builder)
546fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result<PathBuf, BenchError> {
547    let lib_prefix = if cfg!(target_os = "windows") {
548        ""
549    } else {
550        "lib"
551    };
552    let lib_ext = match env::consts::OS {
553        "macos" => "dylib",
554        "linux" => "so",
555        other => {
556            return Err(BenchError::Build(format!(
557                "unsupported host OS for binding generation: {}",
558                other
559            )));
560        }
561    };
562    let path = project_dir.join("target").join("debug").join(format!(
563        "{}{}.{}",
564        lib_prefix,
565        crate_name.replace('-', "_"),
566        lib_ext
567    ));
568    if !path.exists() {
569        return Err(BenchError::Build(format!(
570            "host library for UniFFI not found at {:?}",
571            path
572        )));
573    }
574    Ok(path)
575}
576
577fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
578    let output = cmd
579        .output()
580        .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?;
581    if !output.status.success() {
582        let stderr = String::from_utf8_lossy(&output.stderr);
583        return Err(BenchError::Build(format!(
584            "{} failed: {}",
585            description, stderr
586        )));
587    }
588    Ok(())
589}
590
591/// iOS code signing methods for IPA packaging
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub enum SigningMethod {
594    /// Ad-hoc signing (no Apple ID required, works for BrowserStack testing)
595    AdHoc,
596    /// Development signing (requires Apple Developer account and provisioning profile)
597    Development,
598}
599
600impl SigningMethod {
601    /// Returns the CODE_SIGN_IDENTITY value for xcodebuild
602    fn identity(&self) -> &'static str {
603        match self {
604            SigningMethod::AdHoc => "-",
605            SigningMethod::Development => "iPhone Developer",
606        }
607    }
608
609    /// Returns the export method for ExportOptions.plist
610    fn export_method(&self) -> &'static str {
611        match self {
612            SigningMethod::AdHoc => "ad-hoc",
613            SigningMethod::Development => "development",
614        }
615    }
616}
617
618impl IosBuilder {
619    /// Packages the iOS app as an IPA file for distribution or testing
620    ///
621    /// This requires the app to have been built first with `build()`.
622    /// The IPA can be used for:
623    /// - BrowserStack device testing (ad-hoc signing)
624    /// - Physical device testing (development signing)
625    ///
626    /// # Arguments
627    ///
628    /// * `scheme` - The Xcode scheme to build (e.g., "BenchRunner")
629    /// * `method` - The signing method (AdHoc or Development)
630    ///
631    /// # Returns
632    ///
633    /// * `Ok(PathBuf)` - Path to the generated IPA file
634    /// * `Err(BenchError)` - If the build or packaging fails
635    ///
636    /// # Example
637    ///
638    /// ```no_run
639    /// use mobench_sdk::builders::{IosBuilder, SigningMethod};
640    ///
641    /// let builder = IosBuilder::new(".", "bench-mobile");
642    /// let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
643    /// println!("IPA created at: {:?}", ipa_path);
644    /// # Ok::<(), mobench_sdk::BenchError>(())
645    /// ```
646    pub fn package_ipa(
647        &self,
648        scheme: &str,
649        method: SigningMethod,
650    ) -> Result<PathBuf, BenchError> {
651        let ios_dir = self.project_root.join("ios").join(scheme);
652        let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
653
654        // Verify Xcode project exists
655        if !project_path.exists() {
656            return Err(BenchError::Build(format!(
657                "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.",
658                project_path
659            )));
660        }
661
662        let archive_path = self.project_root.join("target/ios").join(format!("{}.xcarchive", scheme));
663        let export_path = self.project_root.join("target/ios");
664        let ipa_path = export_path.join(format!("{}.ipa", scheme));
665
666        // Create target/ios directory if it doesn't exist
667        fs::create_dir_all(&export_path)
668            .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?;
669
670        println!("Archiving {} for device...", scheme);
671
672        // Step 1: Create archive
673        let mut cmd = Command::new("xcodebuild");
674        cmd.args(&[
675            "-project", project_path.to_str().unwrap(),
676            "-scheme", scheme,
677            "-sdk", "iphoneos",
678            "-configuration", "Release",
679            "-archivePath", archive_path.to_str().unwrap(),
680            "archive",
681            "CODE_SIGN_STYLE=Automatic",
682            &format!("CODE_SIGN_IDENTITY={}", method.identity()),
683        ]);
684
685        if self.verbose {
686            println!("  Running: {:?}", cmd);
687        }
688
689        run_command(cmd, "xcodebuild archive")?;
690
691        println!("Exporting IPA with {:?} signing...", method);
692
693        // Step 2: Create ExportOptions.plist
694        let export_options_path = export_path.join("ExportOptions.plist");
695        self.create_export_options_plist(&export_options_path, method)?;
696
697        // Step 3: Export IPA
698        let mut cmd = Command::new("xcodebuild");
699        cmd.args(&[
700            "-exportArchive",
701            "-archivePath", archive_path.to_str().unwrap(),
702            "-exportPath", export_path.to_str().unwrap(),
703            "-exportOptionsPlist", export_options_path.to_str().unwrap(),
704        ]);
705
706        if self.verbose {
707            println!("  Running: {:?}", cmd);
708        }
709
710        run_command(cmd, "xcodebuild -exportArchive")?;
711
712        // xcodebuild exports to a subdirectory, move IPA to expected location
713        let exported_ipa = export_path.join(format!("{}.ipa", scheme));
714        if !exported_ipa.exists() {
715            // Check in subdirectory (xcodebuild sometimes puts it there)
716            let subdir_ipa = export_path.join(scheme).join(format!("{}.ipa", scheme));
717            if subdir_ipa.exists() {
718                fs::rename(&subdir_ipa, &ipa_path).map_err(|e| {
719                    BenchError::Build(format!("Failed to move IPA to final location: {}", e))
720                })?;
721            } else {
722                return Err(BenchError::Build(format!(
723                    "IPA not found after export. Expected at {:?} or {:?}",
724                    exported_ipa, subdir_ipa
725                )));
726            }
727        } else if exported_ipa != ipa_path {
728            fs::rename(&exported_ipa, &ipa_path).map_err(|e| {
729                BenchError::Build(format!("Failed to move IPA to final location: {}", e))
730            })?;
731        }
732
733        println!("✓ IPA created: {:?}", ipa_path);
734        Ok(ipa_path)
735    }
736
737    /// Creates an ExportOptions.plist file for xcodebuild -exportArchive
738    fn create_export_options_plist(
739        &self,
740        path: &Path,
741        method: SigningMethod,
742    ) -> Result<(), BenchError> {
743        let plist_content = format!(
744            r#"<?xml version="1.0" encoding="UTF-8"?>
745<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
746<plist version="1.0">
747<dict>
748    <key>method</key>
749    <string>{}</string>
750    <key>compileBitcode</key>
751    <false/>
752    <key>stripSwiftSymbols</key>
753    <true/>
754    <key>uploadSymbols</key>
755    <false/>
756    <key>signingStyle</key>
757    <string>automatic</string>
758</dict>
759</plist>
760"#,
761            method.export_method()
762        );
763
764        let mut file = fs::File::create(path).map_err(|e| {
765            BenchError::Build(format!("Failed to create ExportOptions.plist: {}", e))
766        })?;
767
768        file.write_all(plist_content.as_bytes()).map_err(|e| {
769            BenchError::Build(format!("Failed to write ExportOptions.plist: {}", e))
770        })?;
771
772        Ok(())
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    #[test]
781    fn test_ios_builder_creation() {
782        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
783        assert!(!builder.verbose);
784    }
785
786    #[test]
787    fn test_ios_builder_verbose() {
788        let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
789        assert!(builder.verbose);
790    }
791}