1use 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 if !output.status.success() {
123 return Err(BenchError::Build(format!(
124 "`xcodebuild -version` failed with status {}: {}",
125 output.status,
126 String::from_utf8_lossy(&output.stderr)
127 )));
128 }
129 parse_xcode_version(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| {
130 BenchError::Build(format!(
131 "Unable to parse Xcode version from `{}`",
132 String::from_utf8_lossy(&output.stdout).trim()
133 ))
134 })
135}
136
137pub fn minimum_supported_ios_deployment_target_for_xcode(
138 xcode: &XcodeVersion,
139) -> Result<IosDeploymentTarget, BenchError> {
140 let floor = if xcode.major >= 16 { "13.0" } else { "12.0" };
141 IosDeploymentTarget::parse(floor)
142}
143
144pub fn validate_xcode_supports_ios_deployment_target(
145 deployment_target: &IosDeploymentTarget,
146) -> Result<(), BenchError> {
147 let xcode = selected_xcode_version()?;
148 let supported_floor = minimum_supported_ios_deployment_target_for_xcode(&xcode)?;
149 if deployment_target < &supported_floor {
150 return Err(BenchError::Build(format!(
151 "iOS deployment target {deployment_target} requires an older Xcode toolchain; \
152selected Xcode {} supports iOS {}+ in mobench's supported lanes. \
153Use a legacy CI lane with older Xcode, or raise `[ios].deployment_target`.",
154 xcode.raw, supported_floor
155 )));
156 }
157 Ok(())
158}
159
160fn native_c_abi_header(framework_name: &str) -> String {
161 let guard = format!(
162 "{}_MOBENCH_NATIVE_C_ABI_H",
163 framework_name.to_ascii_uppercase()
164 )
165 .replace('-', "_");
166 format!(
167 r#"#ifndef {guard}
168#define {guard}
169
170#include <stdint.h>
171#include <stddef.h>
172
173#ifdef __cplusplus
174extern "C" {{
175#endif
176
177typedef struct MobenchBuf {{
178 uint8_t *ptr;
179 uintptr_t len;
180 uintptr_t cap;
181}} MobenchBuf;
182
183int32_t mobench_run_benchmark_json(const uint8_t *spec_ptr, uintptr_t spec_len, MobenchBuf *out);
184void mobench_free_buf(MobenchBuf *buf);
185const char *mobench_last_error_message(void);
186
187#ifdef __cplusplus
188}}
189#endif
190
191#endif /* {guard} */
192"#,
193 )
194}
195
196pub struct IosBuilder {
225 project_root: PathBuf,
227 output_dir: PathBuf,
229 crate_name: String,
231 verbose: bool,
233 crate_dir: Option<PathBuf>,
235 dry_run: bool,
237 ffi_backend: crate::FfiBackend,
239 deployment_target: IosDeploymentTarget,
241 runner: Option<IosRunner>,
243}
244
245impl IosBuilder {
246 pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
255 let root_input = project_root.into();
256 let root = match root_input.canonicalize() {
260 Ok(path) => path,
261 Err(err) => {
262 eprintln!(
263 "Warning: failed to canonicalize project root `{}`: {}. Using provided path.",
264 root_input.display(),
265 err
266 );
267 root_input
268 }
269 };
270 Self {
271 output_dir: root.join("target/mobench"),
272 project_root: root,
273 crate_name: crate_name.into(),
274 verbose: false,
275 crate_dir: None,
276 dry_run: false,
277 ffi_backend: crate::FfiBackend::Uniffi,
278 deployment_target: IosDeploymentTarget::default_target(),
279 runner: None,
280 }
281 }
282
283 pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
288 self.output_dir = dir.into();
289 self
290 }
291
292 pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
302 self.crate_dir = Some(dir.into());
303 self
304 }
305
306 pub fn verbose(mut self, verbose: bool) -> Self {
308 self.verbose = verbose;
309 self
310 }
311
312 pub fn dry_run(mut self, dry_run: bool) -> Self {
317 self.dry_run = dry_run;
318 self
319 }
320
321 pub fn ffi_backend(mut self, ffi_backend: crate::FfiBackend) -> Self {
325 self.ffi_backend = ffi_backend;
326 self
327 }
328
329 pub fn deployment_target(mut self, deployment_target: IosDeploymentTarget) -> Self {
331 self.deployment_target = deployment_target;
332 self
333 }
334
335 pub fn runner(mut self, runner: Option<IosRunner>) -> Self {
337 self.runner = runner;
338 self
339 }
340
341 pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
356 if self.crate_dir.is_none() {
358 validate_project_root(&self.project_root, &self.crate_name)?;
359 }
360 let runner = resolve_ios_runner(&self.deployment_target, self.runner)?;
361 if !self.dry_run {
362 validate_xcode_supports_ios_deployment_target(&self.deployment_target)?;
363 }
364
365 let framework_name = self.crate_name.replace("-", "_");
366 let ios_dir = self.output_dir.join("ios");
367 let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name));
368
369 if self.dry_run {
370 println!("\n[dry-run] iOS build plan:");
371 println!(
372 " Step 0: Check/generate iOS project scaffolding at {:?}",
373 ios_dir.join("BenchRunner")
374 );
375 if self.ffi_backend.uses_boltffi() {
376 let crate_dir = self.find_crate_dir()?;
377 println!(
378 " Step 1: Write BoltFFI config at {:?}",
379 crate_dir.join("boltffi.toml")
380 );
381 println!(" Step 2: Generate Swift bindings and package xcframework with BoltFFI");
382 println!(
383 " Command: boltffi pack apple --layout split --regenerate {}",
384 if matches!(config.profile, BuildProfile::Release) {
385 "--release"
386 } else {
387 ""
388 }
389 );
390 println!(
391 " Swift output: {:?}",
392 ios_dir.join("BenchRunner/BenchRunner/Generated/BoltFFIGenerated")
393 );
394 println!(" xcframework output: {:?}", xcframework_path);
395 println!(" Step 3: Generate Xcode project with xcodegen (if project.yml exists)");
396 println!(" Command: xcodegen generate");
397
398 return Ok(BuildResult {
399 platform: Target::Ios,
400 app_path: xcframework_path,
401 test_suite_path: None,
402 native_libraries: Vec::new(),
403 });
404 }
405
406 println!(" Step 1: Build Rust libraries for iOS targets");
407 println!(
408 " Command: cargo build --target aarch64-apple-ios --lib {}",
409 if matches!(config.profile, BuildProfile::Release) {
410 "--release"
411 } else {
412 ""
413 }
414 );
415 println!(
416 " Command: cargo build --target aarch64-apple-ios-sim --lib {}",
417 if matches!(config.profile, BuildProfile::Release) {
418 "--release"
419 } else {
420 ""
421 }
422 );
423 println!(
424 " Command: cargo build --target x86_64-apple-ios --lib {}",
425 if matches!(config.profile, BuildProfile::Release) {
426 "--release"
427 } else {
428 ""
429 }
430 );
431 if self.ffi_backend.uses_uniffi() {
432 println!(" Step 2: Generate UniFFI Swift bindings");
433 println!(
434 " Output: {:?}",
435 ios_dir.join("BenchRunner/BenchRunner/Generated")
436 );
437 } else {
438 println!(
439 " Step 2: Skip UniFFI Swift bindings (backend: {})",
440 self.ffi_backend
441 );
442 }
443 println!(" Step 3: Create xcframework at {:?}", xcframework_path);
444 println!(" - ios-arm64/{}.framework (device)", framework_name);
445 println!(
446 " - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)",
447 framework_name
448 );
449 println!(" Step 4: Code-sign xcframework");
450 println!(
451 " Command: codesign --force --deep --sign - {:?}",
452 xcframework_path
453 );
454 println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)");
455 println!(" Command: xcodegen generate");
456
457 return Ok(BuildResult {
459 platform: Target::Ios,
460 app_path: xcframework_path,
461 test_suite_path: None,
462 native_libraries: Vec::new(),
463 });
464 }
465
466 crate::codegen::ensure_ios_project_with_backend_options(
469 &self.output_dir,
470 &self.crate_name,
471 Some(&self.project_root),
472 self.crate_dir.as_deref(),
473 self.ffi_backend,
474 IosProjectOptions {
475 deployment_target: self.deployment_target.clone(),
476 runner,
477 ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs_from_env(),
478 },
479 )?;
480
481 if self.ffi_backend.uses_boltffi() {
482 println!("Generating and packaging BoltFFI iOS bindings...");
483 self.write_boltffi_config()?;
484 let xcframework_path = self.run_boltffi_pack_apple(config)?;
485 self.generate_xcode_project()?;
486
487 let result = BuildResult {
488 platform: Target::Ios,
489 app_path: xcframework_path,
490 test_suite_path: None,
491 native_libraries: Vec::new(),
492 };
493 self.validate_build_artifacts(&result, config)?;
494 return Ok(result);
495 }
496
497 println!("Building Rust libraries for iOS...");
499 self.build_rust_libraries(config)?;
500
501 if self.ffi_backend.uses_uniffi() {
503 println!("Generating UniFFI Swift bindings...");
504 self.generate_uniffi_bindings()?;
505 } else {
506 println!(
507 "Skipping UniFFI Swift bindings for {} backend",
508 self.ffi_backend
509 );
510 }
511
512 println!("Creating xcframework...");
514 let xcframework_path = self.create_xcframework(config)?;
515
516 println!("Code-signing xcframework...");
518 self.codesign_xcframework(&xcframework_path)?;
519
520 let include_dir = self.output_dir.join("ios/include");
522 fs::create_dir_all(&include_dir).map_err(|e| {
523 BenchError::Build(format!(
524 "Failed to create include dir at {}: {}. Check output directory permissions.",
525 include_dir.display(),
526 e
527 ))
528 })?;
529 let header_dest = include_dir.join(format!("{}.h", framework_name));
530 if self.ffi_backend.uses_uniffi() {
531 let header_src = self
532 .find_uniffi_header(&format!("{}FFI.h", framework_name))
533 .ok_or_else(|| {
534 BenchError::Build(format!(
535 "UniFFI header {}FFI.h not found after generation",
536 framework_name
537 ))
538 })?;
539 fs::copy(&header_src, &header_dest).map_err(|e| {
540 BenchError::Build(format!(
541 "Failed to copy UniFFI header to {:?}: {}. Check output directory permissions.",
542 header_dest, e
543 ))
544 })?;
545 } else {
546 fs::write(&header_dest, native_c_abi_header(&framework_name)).map_err(|e| {
547 BenchError::Build(format!(
548 "Failed to write native C ABI header to {:?}: {}. Check output directory permissions.",
549 header_dest, e
550 ))
551 })?;
552 }
553
554 self.generate_xcode_project()?;
556
557 let result = BuildResult {
559 platform: Target::Ios,
560 app_path: xcframework_path,
561 test_suite_path: None,
562 native_libraries: Vec::new(),
563 };
564 self.validate_build_artifacts(&result, config)?;
565
566 Ok(result)
567 }
568
569 fn write_boltffi_config(&self) -> Result<(), BenchError> {
570 let crate_dir = self.find_crate_dir()?;
571 let library_name = self.crate_name.replace('-', "_");
572 let package_component = crate::codegen::sanitize_bundle_id_component(&library_name);
573 let kotlin_package = format!("com.mobench.{package_component}");
574 crate::codegen::write_boltffi_config_with_output_dir(
575 &crate_dir,
576 &library_name,
577 &self.crate_name,
578 "BenchRunner",
579 &kotlin_package,
580 &["arm64".to_string()],
581 &self.output_dir,
582 )
583 }
584
585 fn run_boltffi_pack_apple(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
586 let crate_dir = self.find_crate_dir()?;
587 let framework_name = self.crate_name.replace('-', "_");
588 let xcframework_path = self
589 .output_dir
590 .join("ios")
591 .join(format!("{framework_name}.xcframework"));
592 let mut cmd = Command::new("boltffi");
593 cmd.arg("pack")
594 .arg("apple")
595 .arg("--layout")
596 .arg("split")
597 .arg("--regenerate");
598 if matches!(config.profile, BuildProfile::Release) {
599 cmd.arg("--release");
600 }
601 cmd.current_dir(&crate_dir);
602 run_command(cmd, "boltffi pack apple")?;
603 Ok(xcframework_path)
604 }
605
606 fn validate_build_artifacts(
608 &self,
609 result: &BuildResult,
610 config: &BuildConfig,
611 ) -> Result<(), BenchError> {
612 let mut missing = Vec::new();
613 let framework_name = self.crate_name.replace("-", "_");
614 let profile_dir = match config.profile {
615 BuildProfile::Debug => "debug",
616 BuildProfile::Release => "release",
617 };
618
619 if !result.app_path.exists() {
621 missing.push(format!("XCFramework: {}", result.app_path.display()));
622 }
623
624 if self.ffi_backend.uses_boltffi() {
625 let swift_bindings = self
626 .output_dir
627 .join("ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated")
628 .join(boltffi_swift_bindings_path_fragment(&self.crate_name));
629 if !swift_bindings.exists() {
630 missing.push(format!(
631 "BoltFFI Swift bindings: {}",
632 swift_bindings.display()
633 ));
634 }
635
636 if !missing.is_empty() {
637 return Err(BenchError::Build(format!(
638 "BoltFFI iOS build validation failed.\n\nMissing artifacts:\n{}\n\nCheck the boltffi pack apple output above.",
639 missing
640 .iter()
641 .map(|s| format!(" - {}", s))
642 .collect::<Vec<_>>()
643 .join("\n")
644 )));
645 }
646 return Ok(());
647 }
648
649 let xcframework_path = &result.app_path;
651 let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name));
652 let sim_slice = xcframework_path.join(format!(
654 "ios-arm64_x86_64-simulator/{}.framework",
655 framework_name
656 ));
657
658 if xcframework_path.exists() {
659 if !device_slice.exists() {
660 missing.push(format!(
661 "Device framework slice: {}",
662 device_slice.display()
663 ));
664 }
665 if !sim_slice.exists() {
666 missing.push(format!(
667 "Simulator framework slice (arm64+x86_64): {}",
668 sim_slice.display()
669 ));
670 }
671 }
672
673 let crate_dir = self.find_crate_dir()?;
675 let target_dir = get_cargo_target_dir(&crate_dir)?;
676 let lib_name = format!("lib{}.a", framework_name);
677
678 let device_lib = target_dir
679 .join("aarch64-apple-ios")
680 .join(profile_dir)
681 .join(&lib_name);
682 let sim_arm64_lib = target_dir
683 .join("aarch64-apple-ios-sim")
684 .join(profile_dir)
685 .join(&lib_name);
686 let sim_x86_64_lib = target_dir
687 .join("x86_64-apple-ios")
688 .join(profile_dir)
689 .join(&lib_name);
690
691 if !device_lib.exists() {
692 missing.push(format!("Device static library: {}", device_lib.display()));
693 }
694 if !sim_arm64_lib.exists() {
695 missing.push(format!(
696 "Simulator (arm64) static library: {}",
697 sim_arm64_lib.display()
698 ));
699 }
700 if !sim_x86_64_lib.exists() {
701 missing.push(format!(
702 "Simulator (x86_64) static library: {}",
703 sim_x86_64_lib.display()
704 ));
705 }
706
707 if self.ffi_backend.uses_uniffi() {
708 let swift_bindings = self
709 .output_dir
710 .join("ios/BenchRunner/BenchRunner/Generated")
711 .join(format!("{}.swift", framework_name));
712 if !swift_bindings.exists() {
713 missing.push(format!("Swift bindings: {}", swift_bindings.display()));
714 }
715 }
716
717 if !missing.is_empty() {
718 let critical = missing
719 .iter()
720 .any(|m| m.contains("XCFramework") || m.contains("static library"));
721 if critical {
722 return Err(BenchError::Build(format!(
723 "Build validation failed: Critical artifacts are missing.\n\n\
724 Missing artifacts:\n{}\n\n\
725 This usually means the Rust build step failed. Check the cargo build output above.",
726 missing
727 .iter()
728 .map(|s| format!(" - {}", s))
729 .collect::<Vec<_>>()
730 .join("\n")
731 )));
732 } else {
733 eprintln!(
734 "Warning: Some build artifacts are missing:\n{}\n\
735 The build may still work but some features might be unavailable.",
736 missing
737 .iter()
738 .map(|s| format!(" - {}", s))
739 .collect::<Vec<_>>()
740 .join("\n")
741 );
742 }
743 }
744
745 Ok(())
746 }
747
748 fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
756 if let Some(ref dir) = self.crate_dir {
758 if dir.exists() {
759 return Ok(dir.clone());
760 }
761 return Err(BenchError::Build(format!(
762 "Specified crate path does not exist: {:?}.\n\n\
763 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
764 dir
765 )));
766 }
767
768 let root_cargo_toml = self.project_root.join("Cargo.toml");
771 if root_cargo_toml.exists()
772 && let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml)
773 && pkg_name == self.crate_name
774 {
775 return Ok(self.project_root.clone());
776 }
777
778 let bench_mobile_dir = self.project_root.join("bench-mobile");
780 if bench_mobile_dir.exists() {
781 return Ok(bench_mobile_dir);
782 }
783
784 let crates_dir = self.project_root.join("crates").join(&self.crate_name);
786 if crates_dir.exists() {
787 return Ok(crates_dir);
788 }
789
790 let named_dir = self.project_root.join(&self.crate_name);
792 if named_dir.exists() {
793 return Ok(named_dir);
794 }
795
796 let root_manifest = root_cargo_toml;
797 let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
798 let crates_manifest = crates_dir.join("Cargo.toml");
799 let named_manifest = named_dir.join("Cargo.toml");
800 Err(BenchError::Build(format!(
801 "Benchmark crate '{}' not found.\n\n\
802 Searched locations:\n\
803 - {} (checked [package] name)\n\
804 - {}\n\
805 - {}\n\
806 - {}\n\n\
807 To fix this:\n\
808 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
809 2. Create a bench-mobile/ directory with your benchmark crate, or\n\
810 3. Use --crate-path to specify the benchmark crate location:\n\
811 cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\
812 Common issues:\n\
813 - Typo in crate name (check Cargo.toml [package] name)\n\
814 - Wrong working directory (run from project root)\n\
815 - Missing Cargo.toml in the crate directory\n\n\
816 Run 'cargo mobench init --help' to generate a new benchmark project.",
817 self.crate_name,
818 root_manifest.display(),
819 bench_mobile_manifest.display(),
820 crates_manifest.display(),
821 named_manifest.display(),
822 self.crate_name,
823 )))
824 }
825
826 fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
828 let crate_dir = self.find_crate_dir()?;
829
830 let targets = vec![
832 "aarch64-apple-ios", "aarch64-apple-ios-sim", "x86_64-apple-ios", ];
836
837 self.check_rust_targets(&targets)?;
839 let release_flag = if matches!(config.profile, BuildProfile::Release) {
840 "--release"
841 } else {
842 ""
843 };
844
845 for target in targets {
846 if self.verbose {
847 println!(" Building for {}", target);
848 }
849
850 let mut cmd = Command::new("cargo");
851 cmd.arg("build").arg("--target").arg(target).arg("--lib");
852
853 if !release_flag.is_empty() {
855 cmd.arg(release_flag);
856 }
857
858 cmd.current_dir(&crate_dir);
860
861 let command_hint = if release_flag.is_empty() {
863 format!("cargo build --target {} --lib", target)
864 } else {
865 format!("cargo build --target {} --lib {}", target, release_flag)
866 };
867 let output = cmd.output().map_err(|e| {
868 BenchError::Build(format!(
869 "Failed to run cargo for {}.\n\n\
870 Command: {}\n\
871 Crate directory: {}\n\
872 Error: {}\n\n\
873 Tip: ensure cargo is installed and on PATH.",
874 target,
875 command_hint,
876 crate_dir.display(),
877 e
878 ))
879 })?;
880
881 if !output.status.success() {
882 let stdout = String::from_utf8_lossy(&output.stdout);
883 let stderr = String::from_utf8_lossy(&output.stderr);
884 return Err(BenchError::Build(format!(
885 "cargo build failed for {}.\n\n\
886 Command: {}\n\
887 Crate directory: {}\n\
888 Exit status: {}\n\n\
889 Stdout:\n{}\n\n\
890 Stderr:\n{}\n\n\
891 Tips:\n\
892 - Ensure Xcode command line tools are installed (xcode-select --install)\n\
893 - Confirm Rust targets are installed (rustup target add {})",
894 target,
895 command_hint,
896 crate_dir.display(),
897 output.status,
898 stdout,
899 stderr,
900 target
901 )));
902 }
903 }
904
905 Ok(())
906 }
907
908 fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
914 let sysroot = Command::new("rustc")
915 .args(["--print", "sysroot"])
916 .output()
917 .ok()
918 .and_then(|o| {
919 if o.status.success() {
920 String::from_utf8(o.stdout).ok()
921 } else {
922 None
923 }
924 })
925 .map(|s| s.trim().to_string());
926
927 for target in targets {
928 let installed = if let Some(ref root) = sysroot {
929 let lib_dir =
931 std::path::Path::new(root).join(format!("lib/rustlib/{}/lib", target));
932 lib_dir.exists()
933 } else {
934 let output = Command::new("rustup")
936 .args(["target", "list", "--installed"])
937 .output()
938 .ok();
939 output
940 .map(|o| String::from_utf8_lossy(&o.stdout).contains(target))
941 .unwrap_or(false)
942 };
943
944 if !installed {
945 return Err(BenchError::Build(format!(
946 "Rust target '{}' is not installed.\n\n\
947 This target is required to compile for iOS.\n\n\
948 To install:\n\
949 rustup target add {}\n\n\
950 For a complete iOS setup, you need all three:\n\
951 rustup target add aarch64-apple-ios # Device\n\
952 rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)\n\
953 rustup target add x86_64-apple-ios # Simulator (Intel Macs)",
954 target, target
955 )));
956 }
957 }
958
959 Ok(())
960 }
961
962 fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
964 let crate_dir = self.find_crate_dir()?;
965 let crate_name_underscored = self.crate_name.replace("-", "_");
966
967 let bindings_path = self
970 .output_dir
971 .join("ios")
972 .join("BenchRunner")
973 .join("BenchRunner")
974 .join("Generated")
975 .join(format!("{}.swift", crate_name_underscored));
976 let had_existing_bindings = bindings_path.exists();
977 if had_existing_bindings && self.verbose {
978 println!(
979 " Found existing Swift bindings at {:?}; regenerating to keep the UniFFI schema current",
980 bindings_path
981 );
982 }
983
984 let mut build_cmd = Command::new("cargo");
986 build_cmd.arg("build");
987 build_cmd.current_dir(&crate_dir);
988 run_command(build_cmd, "cargo build (host)")?;
989
990 let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
991 let out_dir = self
992 .output_dir
993 .join("ios")
994 .join("BenchRunner")
995 .join("BenchRunner")
996 .join("Generated");
997 fs::create_dir_all(&out_dir).map_err(|e| {
998 BenchError::Build(format!(
999 "Failed to create Swift bindings dir at {}: {}. Check output directory permissions.",
1000 out_dir.display(),
1001 e
1002 ))
1003 })?;
1004
1005 let cargo_run_result = Command::new("cargo")
1007 .args([
1008 "run",
1009 "-p",
1010 &self.crate_name,
1011 "--bin",
1012 "uniffi-bindgen",
1013 "--",
1014 ])
1015 .arg("generate")
1016 .arg("--library")
1017 .arg(&lib_path)
1018 .arg("--language")
1019 .arg("swift")
1020 .arg("--out-dir")
1021 .arg(&out_dir)
1022 .current_dir(&crate_dir)
1023 .output();
1024
1025 let use_cargo_run = cargo_run_result
1026 .as_ref()
1027 .map(|o| o.status.success())
1028 .unwrap_or(false);
1029
1030 if use_cargo_run {
1031 if self.verbose {
1032 println!(" Generated bindings using cargo run uniffi-bindgen");
1033 }
1034 } else {
1035 let uniffi_available = Command::new("uniffi-bindgen")
1037 .arg("--version")
1038 .output()
1039 .map(|o| o.status.success())
1040 .unwrap_or(false);
1041
1042 if !uniffi_available {
1043 if had_existing_bindings {
1044 if self.verbose {
1045 println!(
1046 " Warning: uniffi-bindgen is unavailable; keeping existing Swift bindings at {:?}",
1047 bindings_path
1048 );
1049 }
1050 return Ok(());
1051 }
1052 return Err(BenchError::Build(
1053 "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
1054 To fix this, either:\n\
1055 1. Add a uniffi-bindgen binary to your crate:\n\
1056 [[bin]]\n\
1057 name = \"uniffi-bindgen\"\n\
1058 path = \"src/bin/uniffi-bindgen.rs\"\n\n\
1059 2. Or install a matching uniffi-bindgen CLI globally:\n\
1060 cargo install --git https://github.com/mozilla/uniffi-rs --tag <uniffi-tag> uniffi-bindgen-cli --bin uniffi-bindgen\n\n\
1061 3. Or pre-generate bindings and commit them."
1062 .to_string(),
1063 ));
1064 }
1065
1066 let mut cmd = Command::new("uniffi-bindgen");
1067 cmd.arg("generate")
1068 .arg("--library")
1069 .arg(&lib_path)
1070 .arg("--language")
1071 .arg("swift")
1072 .arg("--out-dir")
1073 .arg(&out_dir);
1074 if let Err(error) = run_command(cmd, "uniffi-bindgen swift") {
1075 if had_existing_bindings {
1076 if self.verbose {
1077 println!(
1078 " Warning: failed to regenerate Swift bindings ({error}); keeping existing bindings at {:?}",
1079 bindings_path
1080 );
1081 }
1082 return Ok(());
1083 }
1084 return Err(error);
1085 }
1086 }
1087
1088 if self.verbose {
1089 println!(" Generated UniFFI Swift bindings at {:?}", out_dir);
1090 }
1091
1092 Ok(())
1093 }
1094
1095 fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1097 let profile_dir = match config.profile {
1098 BuildProfile::Debug => "debug",
1099 BuildProfile::Release => "release",
1100 };
1101
1102 let crate_dir = self.find_crate_dir()?;
1103 let target_dir = get_cargo_target_dir(&crate_dir)?;
1104 let xcframework_dir = self.output_dir.join("ios");
1105 let framework_name = &self.crate_name.replace("-", "_");
1106 let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
1107
1108 if xcframework_path.exists() {
1110 fs::remove_dir_all(&xcframework_path).map_err(|e| {
1111 BenchError::Build(format!(
1112 "Failed to remove old xcframework at {}: {}. Close any tools using it and retry.",
1113 xcframework_path.display(),
1114 e
1115 ))
1116 })?;
1117 }
1118
1119 fs::create_dir_all(&xcframework_dir).map_err(|e| {
1121 BenchError::Build(format!(
1122 "Failed to create xcframework directory at {}: {}. Check output directory permissions.",
1123 xcframework_dir.display(),
1124 e
1125 ))
1126 })?;
1127
1128 self.create_framework_slice(
1131 &target_dir.join("aarch64-apple-ios").join(profile_dir),
1132 &xcframework_path.join("ios-arm64"),
1133 framework_name,
1134 "ios",
1135 self.ffi_backend,
1136 )?;
1137
1138 self.create_simulator_framework_slice(
1140 &target_dir,
1141 profile_dir,
1142 &xcframework_path.join("ios-arm64_x86_64-simulator"),
1143 framework_name,
1144 self.ffi_backend,
1145 )?;
1146
1147 self.create_xcframework_plist(&xcframework_path, framework_name)?;
1149
1150 Ok(xcframework_path)
1151 }
1152
1153 fn create_framework_slice(
1155 &self,
1156 lib_path: &Path,
1157 output_dir: &Path,
1158 framework_name: &str,
1159 platform: &str,
1160 ffi_backend: crate::FfiBackend,
1161 ) -> Result<(), BenchError> {
1162 let framework_dir = output_dir.join(format!("{}.framework", framework_name));
1163 let headers_dir = framework_dir.join("Headers");
1164
1165 fs::create_dir_all(&headers_dir).map_err(|e| {
1167 BenchError::Build(format!(
1168 "Failed to create framework directories at {}: {}. Check output directory permissions.",
1169 headers_dir.display(),
1170 e
1171 ))
1172 })?;
1173
1174 let src_lib = lib_path.join(format!("lib{}.a", framework_name));
1176 let dest_lib = framework_dir.join(framework_name);
1177
1178 if !src_lib.exists() {
1179 return Err(BenchError::Build(format!(
1180 "Static library not found at {}.\n\n\
1181 Expected output from cargo build --target <target> --lib.\n\
1182 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
1183 src_lib.display()
1184 )));
1185 }
1186
1187 fs::copy(&src_lib, &dest_lib).map_err(|e| {
1188 BenchError::Build(format!(
1189 "Failed to copy static library from {} to {}: {}. Check output directory permissions.",
1190 src_lib.display(),
1191 dest_lib.display(),
1192 e
1193 ))
1194 })?;
1195
1196 let header_name = format!("{}FFI.h", framework_name);
1198 let dest_header = headers_dir.join(&header_name);
1199 if ffi_backend.uses_uniffi() {
1200 let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
1201 BenchError::Build(format!(
1202 "UniFFI header {} not found; run binding generation before building",
1203 header_name
1204 ))
1205 })?;
1206 fs::copy(&header_path, &dest_header).map_err(|e| {
1207 BenchError::Build(format!(
1208 "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
1209 header_path.display(),
1210 dest_header.display(),
1211 e
1212 ))
1213 })?;
1214 } else {
1215 fs::write(&dest_header, native_c_abi_header(framework_name)).map_err(|e| {
1216 BenchError::Build(format!(
1217 "Failed to write native C ABI header to {}: {}. Check output directory permissions.",
1218 dest_header.display(),
1219 e
1220 ))
1221 })?;
1222 }
1223
1224 let modulemap_content = format!(
1226 "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
1227 framework_name, framework_name
1228 );
1229 let modulemap_path = headers_dir.join("module.modulemap");
1230 fs::write(&modulemap_path, modulemap_content).map_err(|e| {
1231 BenchError::Build(format!(
1232 "Failed to write module.modulemap at {}: {}. Check output directory permissions.",
1233 modulemap_path.display(),
1234 e
1235 ))
1236 })?;
1237
1238 self.create_framework_plist(&framework_dir, framework_name, platform)?;
1240
1241 Ok(())
1242 }
1243
1244 fn create_simulator_framework_slice(
1246 &self,
1247 target_dir: &Path,
1248 profile_dir: &str,
1249 output_dir: &Path,
1250 framework_name: &str,
1251 ffi_backend: crate::FfiBackend,
1252 ) -> Result<(), BenchError> {
1253 let framework_dir = output_dir.join(format!("{}.framework", framework_name));
1254 let headers_dir = framework_dir.join("Headers");
1255
1256 fs::create_dir_all(&headers_dir).map_err(|e| {
1258 BenchError::Build(format!(
1259 "Failed to create framework directories at {}: {}. Check output directory permissions.",
1260 headers_dir.display(),
1261 e
1262 ))
1263 })?;
1264
1265 let arm64_lib = target_dir
1267 .join("aarch64-apple-ios-sim")
1268 .join(profile_dir)
1269 .join(format!("lib{}.a", framework_name));
1270 let x86_64_lib = target_dir
1271 .join("x86_64-apple-ios")
1272 .join(profile_dir)
1273 .join(format!("lib{}.a", framework_name));
1274
1275 if !arm64_lib.exists() {
1277 return Err(BenchError::Build(format!(
1278 "Simulator library (arm64) not found at {}.\n\n\
1279 Expected output from cargo build --target aarch64-apple-ios-sim --lib.\n\
1280 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
1281 arm64_lib.display()
1282 )));
1283 }
1284 if !x86_64_lib.exists() {
1285 return Err(BenchError::Build(format!(
1286 "Simulator library (x86_64) not found at {}.\n\n\
1287 Expected output from cargo build --target x86_64-apple-ios --lib.\n\
1288 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
1289 x86_64_lib.display()
1290 )));
1291 }
1292
1293 let dest_lib = framework_dir.join(framework_name);
1295 let output = Command::new("lipo")
1296 .arg("-create")
1297 .arg(&arm64_lib)
1298 .arg(&x86_64_lib)
1299 .arg("-output")
1300 .arg(&dest_lib)
1301 .output()
1302 .map_err(|e| {
1303 BenchError::Build(format!(
1304 "Failed to run lipo to create universal simulator binary.\n\n\
1305 Command: lipo -create {} {} -output {}\n\
1306 Error: {}\n\n\
1307 Ensure Xcode command line tools are installed: xcode-select --install",
1308 arm64_lib.display(),
1309 x86_64_lib.display(),
1310 dest_lib.display(),
1311 e
1312 ))
1313 })?;
1314
1315 if !output.status.success() {
1316 let stderr = String::from_utf8_lossy(&output.stderr);
1317 return Err(BenchError::Build(format!(
1318 "lipo failed to create universal simulator binary.\n\n\
1319 Command: lipo -create {} {} -output {}\n\
1320 Exit status: {}\n\
1321 Stderr: {}\n\n\
1322 Ensure both libraries are valid static libraries.",
1323 arm64_lib.display(),
1324 x86_64_lib.display(),
1325 dest_lib.display(),
1326 output.status,
1327 stderr
1328 )));
1329 }
1330
1331 if self.verbose {
1332 println!(
1333 " Created universal simulator binary (arm64 + x86_64) at {:?}",
1334 dest_lib
1335 );
1336 }
1337
1338 let header_name = format!("{}FFI.h", framework_name);
1340 let dest_header = headers_dir.join(&header_name);
1341 if ffi_backend.uses_uniffi() {
1342 let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
1343 BenchError::Build(format!(
1344 "UniFFI header {} not found; run binding generation before building",
1345 header_name
1346 ))
1347 })?;
1348 fs::copy(&header_path, &dest_header).map_err(|e| {
1349 BenchError::Build(format!(
1350 "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
1351 header_path.display(),
1352 dest_header.display(),
1353 e
1354 ))
1355 })?;
1356 } else {
1357 fs::write(&dest_header, native_c_abi_header(framework_name)).map_err(|e| {
1358 BenchError::Build(format!(
1359 "Failed to write native C ABI header to {}: {}. Check output directory permissions.",
1360 dest_header.display(),
1361 e
1362 ))
1363 })?;
1364 }
1365
1366 let modulemap_content = format!(
1368 "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
1369 framework_name, framework_name
1370 );
1371 let modulemap_path = headers_dir.join("module.modulemap");
1372 fs::write(&modulemap_path, modulemap_content).map_err(|e| {
1373 BenchError::Build(format!(
1374 "Failed to write module.modulemap at {}: {}. Check output directory permissions.",
1375 modulemap_path.display(),
1376 e
1377 ))
1378 })?;
1379
1380 self.create_framework_plist(&framework_dir, framework_name, "ios-simulator")?;
1382
1383 Ok(())
1384 }
1385
1386 fn create_framework_plist(
1388 &self,
1389 framework_dir: &Path,
1390 framework_name: &str,
1391 platform: &str,
1392 ) -> Result<(), BenchError> {
1393 let bundle_id: String = framework_name
1396 .chars()
1397 .filter(|c| c.is_ascii_alphanumeric())
1398 .collect::<String>()
1399 .to_lowercase();
1400 let plist_content = format!(
1401 r#"<?xml version="1.0" encoding="UTF-8"?>
1402<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1403<plist version="1.0">
1404<dict>
1405 <key>CFBundleExecutable</key>
1406 <string>{}</string>
1407 <key>CFBundleIdentifier</key>
1408 <string>dev.world.{}</string>
1409 <key>CFBundleInfoDictionaryVersion</key>
1410 <string>6.0</string>
1411 <key>CFBundleName</key>
1412 <string>{}</string>
1413 <key>CFBundlePackageType</key>
1414 <string>FMWK</string>
1415 <key>CFBundleShortVersionString</key>
1416 <string>0.1.0</string>
1417 <key>CFBundleVersion</key>
1418 <string>1</string>
1419 <key>CFBundleSupportedPlatforms</key>
1420 <array>
1421 <string>{}</string>
1422 </array>
1423</dict>
1424</plist>"#,
1425 framework_name,
1426 bundle_id,
1427 framework_name,
1428 if platform == "ios" {
1429 "iPhoneOS"
1430 } else {
1431 "iPhoneSimulator"
1432 }
1433 );
1434
1435 let plist_path = framework_dir.join("Info.plist");
1436 fs::write(&plist_path, plist_content).map_err(|e| {
1437 BenchError::Build(format!(
1438 "Failed to write framework Info.plist at {}: {}. Check output directory permissions.",
1439 plist_path.display(),
1440 e
1441 ))
1442 })?;
1443
1444 Ok(())
1445 }
1446
1447 fn create_xcframework_plist(
1449 &self,
1450 xcframework_path: &Path,
1451 framework_name: &str,
1452 ) -> Result<(), BenchError> {
1453 let plist_content = format!(
1454 r#"<?xml version="1.0" encoding="UTF-8"?>
1455<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1456<plist version="1.0">
1457<dict>
1458 <key>AvailableLibraries</key>
1459 <array>
1460 <dict>
1461 <key>LibraryIdentifier</key>
1462 <string>ios-arm64</string>
1463 <key>LibraryPath</key>
1464 <string>{}.framework</string>
1465 <key>SupportedArchitectures</key>
1466 <array>
1467 <string>arm64</string>
1468 </array>
1469 <key>SupportedPlatform</key>
1470 <string>ios</string>
1471 </dict>
1472 <dict>
1473 <key>LibraryIdentifier</key>
1474 <string>ios-arm64_x86_64-simulator</string>
1475 <key>LibraryPath</key>
1476 <string>{}.framework</string>
1477 <key>SupportedArchitectures</key>
1478 <array>
1479 <string>arm64</string>
1480 <string>x86_64</string>
1481 </array>
1482 <key>SupportedPlatform</key>
1483 <string>ios</string>
1484 <key>SupportedPlatformVariant</key>
1485 <string>simulator</string>
1486 </dict>
1487 </array>
1488 <key>CFBundlePackageType</key>
1489 <string>XFWK</string>
1490 <key>XCFrameworkFormatVersion</key>
1491 <string>1.0</string>
1492</dict>
1493</plist>"#,
1494 framework_name, framework_name
1495 );
1496
1497 let plist_path = xcframework_path.join("Info.plist");
1498 fs::write(&plist_path, plist_content).map_err(|e| {
1499 BenchError::Build(format!(
1500 "Failed to write xcframework Info.plist at {}: {}. Check output directory permissions.",
1501 plist_path.display(),
1502 e
1503 ))
1504 })?;
1505
1506 Ok(())
1507 }
1508
1509 fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
1516 let output = Command::new("codesign")
1517 .arg("--force")
1518 .arg("--deep")
1519 .arg("--sign")
1520 .arg("-")
1521 .arg(xcframework_path)
1522 .output()
1523 .map_err(|e| {
1524 BenchError::Build(format!(
1525 "Failed to run codesign.\n\n\
1526 XCFramework: {}\n\
1527 Error: {}\n\n\
1528 Ensure Xcode command line tools are installed:\n\
1529 xcode-select --install\n\n\
1530 The xcframework must be signed for Xcode to accept it.",
1531 xcframework_path.display(),
1532 e
1533 ))
1534 })?;
1535
1536 if output.status.success() {
1537 if self.verbose {
1538 println!(" Successfully code-signed xcframework");
1539 }
1540 Ok(())
1541 } else {
1542 let stderr = String::from_utf8_lossy(&output.stderr);
1543 Err(BenchError::Build(format!(
1544 "codesign failed to sign xcframework.\n\n\
1545 XCFramework: {}\n\
1546 Exit status: {}\n\
1547 Stderr: {}\n\n\
1548 Ensure you have valid signing credentials:\n\
1549 security find-identity -v -p codesigning\n\n\
1550 For ad-hoc signing (most common), the '-' identity should work.\n\
1551 If signing continues to fail, check that the xcframework structure is valid.",
1552 xcframework_path.display(),
1553 output.status,
1554 stderr
1555 )))
1556 }
1557 }
1558
1559 fn generate_xcode_project(&self) -> Result<(), BenchError> {
1569 let ios_dir = self.output_dir.join("ios");
1570 let project_yml = ios_dir.join("BenchRunner/project.yml");
1571
1572 if !project_yml.exists() {
1573 if self.verbose {
1574 println!(" No project.yml found, skipping xcodegen");
1575 }
1576 return Ok(());
1577 }
1578
1579 if self.verbose {
1580 println!(" Generating Xcode project with xcodegen");
1581 }
1582
1583 let project_dir = ios_dir.join("BenchRunner");
1584 let output = Command::new("xcodegen")
1585 .arg("generate")
1586 .current_dir(&project_dir)
1587 .output()
1588 .map_err(|e| {
1589 BenchError::Build(format!(
1590 "Failed to run xcodegen.\n\n\
1591 project.yml found at: {}\n\
1592 Working directory: {}\n\
1593 Error: {}\n\n\
1594 xcodegen is required to generate the Xcode project.\n\
1595 Install it with:\n\
1596 brew install xcodegen\n\n\
1597 After installation, re-run the build.",
1598 project_yml.display(),
1599 project_dir.display(),
1600 e
1601 ))
1602 })?;
1603
1604 if output.status.success() {
1605 if self.verbose {
1606 println!(" Successfully generated Xcode project");
1607 }
1608 Ok(())
1609 } else {
1610 let stdout = String::from_utf8_lossy(&output.stdout);
1611 let stderr = String::from_utf8_lossy(&output.stderr);
1612 Err(BenchError::Build(format!(
1613 "xcodegen failed.\n\n\
1614 Command: xcodegen generate\n\
1615 Working directory: {}\n\
1616 Exit status: {}\n\n\
1617 Stdout:\n{}\n\n\
1618 Stderr:\n{}\n\n\
1619 Check that project.yml is valid YAML and has correct xcodegen syntax.\n\
1620 Try running 'xcodegen generate' manually in {} for more details.",
1621 project_dir.display(),
1622 output.status,
1623 stdout,
1624 stderr,
1625 project_dir.display()
1626 )))
1627 }
1628 }
1629
1630 fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
1632 let swift_dir = self
1634 .output_dir
1635 .join("ios/BenchRunner/BenchRunner/Generated");
1636 let candidate_swift = swift_dir.join(header_name);
1637 if candidate_swift.exists() {
1638 return Some(candidate_swift);
1639 }
1640
1641 let crate_dir = self.find_crate_dir().ok()?;
1643 let target_dir = get_cargo_target_dir(&crate_dir).ok()?;
1644 let candidate = target_dir.join("uniffi").join(header_name);
1646 if candidate.exists() {
1647 return Some(candidate);
1648 }
1649
1650 let mut stack = vec![target_dir];
1652 while let Some(dir) = stack.pop() {
1653 if let Ok(entries) = fs::read_dir(&dir) {
1654 for entry in entries.flatten() {
1655 let path = entry.path();
1656 if path.is_dir() {
1657 if let Some(name) = path.file_name().and_then(|n| n.to_str())
1659 && name == "incremental"
1660 {
1661 continue;
1662 }
1663 stack.push(path);
1664 } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
1665 && name == header_name
1666 {
1667 return Some(path);
1668 }
1669 }
1670 }
1671 }
1672
1673 None
1674 }
1675}
1676
1677#[allow(clippy::collapsible_if)]
1678fn find_codesign_identity() -> Option<String> {
1679 let output = Command::new("security")
1680 .args(["find-identity", "-v", "-p", "codesigning"])
1681 .output()
1682 .ok()?;
1683 if !output.status.success() {
1684 return None;
1685 }
1686 let stdout = String::from_utf8_lossy(&output.stdout);
1687 let mut identities = Vec::new();
1688 for line in stdout.lines() {
1689 if let Some(start) = line.find('"') {
1690 if let Some(end) = line[start + 1..].find('"') {
1691 identities.push(line[start + 1..start + 1 + end].to_string());
1692 }
1693 }
1694 }
1695 let preferred = [
1696 "Apple Distribution",
1697 "iPhone Distribution",
1698 "Apple Development",
1699 "iPhone Developer",
1700 ];
1701 for label in preferred {
1702 if let Some(identity) = identities.iter().find(|i| i.contains(label)) {
1703 return Some(identity.clone());
1704 }
1705 }
1706 identities.first().cloned()
1707}
1708
1709#[allow(clippy::collapsible_if)]
1710fn find_provisioning_profile() -> Option<PathBuf> {
1711 if let Ok(path) = env::var("MOBENCH_IOS_PROFILE") {
1712 let profile = PathBuf::from(path);
1713 if profile.exists() {
1714 return Some(profile);
1715 }
1716 }
1717 let home = env::var("HOME").ok()?;
1718 let profiles_dir = PathBuf::from(home).join("Library/MobileDevice/Provisioning Profiles");
1719 let entries = fs::read_dir(&profiles_dir).ok()?;
1720 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
1721 for entry in entries.flatten() {
1722 let path = entry.path();
1723 if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") {
1724 continue;
1725 }
1726 if let Ok(metadata) = entry.metadata()
1727 && let Ok(modified) = metadata.modified()
1728 {
1729 match &newest {
1730 Some((current, _)) if *current >= modified => {}
1731 _ => newest = Some((modified, path)),
1732 }
1733 }
1734 }
1735 newest.map(|(_, path)| path)
1736}
1737
1738fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), BenchError> {
1739 let dest = app_path.join("embedded.mobileprovision");
1740 fs::copy(profile, &dest).map_err(|e| {
1741 BenchError::Build(format!(
1742 "Failed to embed provisioning profile at {:?}: {}. Check the profile path and file permissions.",
1743 dest, e
1744 ))
1745 })?;
1746 Ok(())
1747}
1748
1749fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> {
1750 let output = Command::new("codesign")
1751 .args(["--force", "--deep", "--sign", identity])
1752 .arg(app_path)
1753 .output()
1754 .map_err(|e| {
1755 BenchError::Build(format!(
1756 "Failed to run codesign: {}. Ensure Xcode command line tools are installed.",
1757 e
1758 ))
1759 })?;
1760 if !output.status.success() {
1761 let stderr = String::from_utf8_lossy(&output.stderr);
1762 return Err(BenchError::Build(format!(
1763 "codesign failed: {}. Verify you have a valid signing identity.",
1764 stderr
1765 )));
1766 }
1767 Ok(())
1768}
1769
1770fn pascalize_first(value: &str) -> String {
1771 let mut chars = value.chars();
1772 match chars.next() {
1773 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
1774 None => String::new(),
1775 }
1776}
1777
1778fn boltffi_swift_bindings_filename(crate_name: &str) -> String {
1779 format!("{}BoltFFI.swift", pascalize_first(crate_name))
1780}
1781
1782fn boltffi_swift_bindings_path_fragment(crate_name: &str) -> PathBuf {
1783 PathBuf::from("BoltFFI").join(boltffi_swift_bindings_filename(crate_name))
1784}
1785
1786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1788pub enum SigningMethod {
1789 AdHoc,
1791 Development,
1793}
1794
1795impl IosBuilder {
1796 pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
1824 let ios_dir = self.output_dir.join("ios").join(scheme);
1827 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
1828
1829 if !project_path.exists() {
1831 return Err(BenchError::Build(format!(
1832 "Xcode project not found at {}.\n\n\
1833 Run `cargo mobench build --target ios` first or check --output-dir.",
1834 project_path.display()
1835 )));
1836 }
1837
1838 let export_path = self.output_dir.join("ios");
1839 let ipa_path = export_path.join(format!("{}.ipa", scheme));
1840
1841 fs::create_dir_all(&export_path).map_err(|e| {
1843 BenchError::Build(format!(
1844 "Failed to create export directory at {}: {}. Check output directory permissions.",
1845 export_path.display(),
1846 e
1847 ))
1848 })?;
1849
1850 println!("Building {} for device...", scheme);
1851
1852 let build_dir = self.output_dir.join("ios/build");
1854 let build_configuration = "Release";
1858 let mut cmd = Command::new("xcodebuild");
1859 cmd.arg("-project")
1860 .arg(&project_path)
1861 .arg("-scheme")
1862 .arg(scheme)
1863 .arg("-destination")
1864 .arg("generic/platform=iOS")
1865 .arg("-sdk")
1866 .arg("iphoneos")
1867 .arg("-configuration")
1868 .arg(build_configuration)
1869 .arg("-derivedDataPath")
1870 .arg(&build_dir)
1871 .arg("build");
1872
1873 match method {
1875 SigningMethod::AdHoc => {
1876 cmd.args([
1880 "VALIDATE_PRODUCT=NO",
1881 "CODE_SIGN_STYLE=Manual",
1882 "CODE_SIGN_IDENTITY=",
1883 "CODE_SIGNING_ALLOWED=NO",
1884 "CODE_SIGNING_REQUIRED=NO",
1885 "DEVELOPMENT_TEAM=",
1886 "PROVISIONING_PROFILE_SPECIFIER=",
1887 ]);
1888 }
1889 SigningMethod::Development => {
1890 cmd.args([
1892 "CODE_SIGN_STYLE=Automatic",
1893 "CODE_SIGN_IDENTITY=iPhone Developer",
1894 ]);
1895 }
1896 }
1897
1898 if self.verbose {
1899 println!(" Running: {:?}", cmd);
1900 }
1901
1902 let build_result = cmd.output();
1904
1905 let app_path = build_dir
1907 .join(format!("Build/Products/{}-iphoneos", build_configuration))
1908 .join(format!("{}.app", scheme));
1909
1910 if !app_path.exists() {
1911 match build_result {
1912 Ok(output) => {
1913 let stdout = String::from_utf8_lossy(&output.stdout);
1914 let stderr = String::from_utf8_lossy(&output.stderr);
1915 return Err(BenchError::Build(format!(
1916 "xcodebuild build failed and app bundle was not created.\n\n\
1917 Project: {}\n\
1918 Scheme: {}\n\
1919 Configuration: {}\n\
1920 Derived data: {}\n\
1921 Exit status: {}\n\n\
1922 Stdout:\n{}\n\n\
1923 Stderr:\n{}\n\n\
1924 Tip: run xcodebuild manually to inspect the failure.",
1925 project_path.display(),
1926 scheme,
1927 build_configuration,
1928 build_dir.display(),
1929 output.status,
1930 stdout,
1931 stderr
1932 )));
1933 }
1934 Err(err) => {
1935 return Err(BenchError::Build(format!(
1936 "Failed to run xcodebuild: {}.\n\n\
1937 App bundle not found at {}.\n\
1938 Check that Xcode command line tools are installed.",
1939 err,
1940 app_path.display()
1941 )));
1942 }
1943 }
1944 }
1945
1946 if self.verbose {
1947 println!(" App bundle created successfully at {:?}", app_path);
1948 }
1949
1950 let build_log_path = export_path.join("ipa-build.log");
1951 if let Ok(output) = &build_result
1952 && !output.status.success()
1953 {
1954 let mut log = String::new();
1955 log.push_str("STDOUT:\n");
1956 log.push_str(&String::from_utf8_lossy(&output.stdout));
1957 log.push_str("\n\nSTDERR:\n");
1958 log.push_str(&String::from_utf8_lossy(&output.stderr));
1959 let _ = fs::write(&build_log_path, log);
1960 println!(
1961 "Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing. Log: {}",
1962 output.status,
1963 app_path.display(),
1964 build_log_path.display()
1965 );
1966 }
1967
1968 let source_info_plist = ios_dir.join(scheme).join("Info.plist");
1969 if let Err(bundle_err) =
1970 self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme)
1971 {
1972 if let Ok(output) = &build_result
1973 && !output.status.success()
1974 {
1975 let stdout = String::from_utf8_lossy(&output.stdout);
1976 let stderr = String::from_utf8_lossy(&output.stderr);
1977 return Err(BenchError::Build(format!(
1978 "xcodebuild build produced an incomplete app bundle.\n\n\
1979 Project: {}\n\
1980 Scheme: {}\n\
1981 Configuration: {}\n\
1982 Derived data: {}\n\
1983 Exit status: {}\n\
1984 Log: {}\n\n\
1985 Bundle validation: {}\n\n\
1986 Stdout:\n{}\n\n\
1987 Stderr:\n{}",
1988 project_path.display(),
1989 scheme,
1990 build_configuration,
1991 build_dir.display(),
1992 output.status,
1993 build_log_path.display(),
1994 bundle_err,
1995 stdout,
1996 stderr
1997 )));
1998 }
1999 return Err(bundle_err);
2000 }
2001
2002 if matches!(method, SigningMethod::AdHoc) {
2003 let profile = find_provisioning_profile();
2004 let identity = find_codesign_identity();
2005 match (profile.as_ref(), identity.as_ref()) {
2006 (Some(profile), Some(identity)) => {
2007 embed_provisioning_profile(&app_path, profile)?;
2008 codesign_bundle(&app_path, identity)?;
2009 if self.verbose {
2010 println!(" Signed app bundle with identity {}", identity);
2011 }
2012 }
2013 _ => {
2014 let output = Command::new("codesign")
2015 .arg("--force")
2016 .arg("--deep")
2017 .arg("--sign")
2018 .arg("-")
2019 .arg(&app_path)
2020 .output();
2021 match output {
2022 Ok(output) if output.status.success() => {
2023 println!(
2024 "Warning: Signed app bundle without provisioning profile; BrowserStack install may fail."
2025 );
2026 }
2027 Ok(output) => {
2028 let stderr = String::from_utf8_lossy(&output.stderr);
2029 println!("Warning: Ad-hoc signing failed: {}", stderr);
2030 }
2031 Err(err) => {
2032 println!("Warning: Could not run codesign: {}", err);
2033 }
2034 }
2035 }
2036 }
2037 }
2038
2039 println!("Creating IPA from app bundle...");
2040
2041 let payload_dir = export_path.join("Payload");
2046 if payload_dir.exists() {
2047 fs::remove_dir_all(&payload_dir).map_err(|e| {
2048 BenchError::Build(format!(
2049 "Failed to remove old Payload dir at {}: {}. Close any tools using it and retry.",
2050 payload_dir.display(),
2051 e
2052 ))
2053 })?;
2054 }
2055 fs::create_dir_all(&payload_dir).map_err(|e| {
2056 BenchError::Build(format!(
2057 "Failed to create Payload dir at {}: {}. Check output directory permissions.",
2058 payload_dir.display(),
2059 e
2060 ))
2061 })?;
2062
2063 let dest_app = payload_dir.join(format!("{}.app", scheme));
2065 self.copy_bundle_with_ditto(&app_path, &dest_app)?;
2066
2067 if ipa_path.exists() {
2069 fs::remove_file(&ipa_path).map_err(|e| {
2070 BenchError::Build(format!(
2071 "Failed to remove old IPA at {}: {}. Check file permissions.",
2072 ipa_path.display(),
2073 e
2074 ))
2075 })?;
2076 }
2077
2078 let mut cmd = Command::new("ditto");
2079 cmd.arg("-c")
2080 .arg("-k")
2081 .arg("--sequesterRsrc")
2082 .arg("--keepParent")
2083 .arg("Payload")
2084 .arg(&ipa_path)
2085 .current_dir(&export_path);
2086
2087 if self.verbose {
2088 println!(" Running: {:?}", cmd);
2089 }
2090
2091 run_command(cmd, "create IPA archive with ditto")?;
2092 self.validate_ipa_archive(&ipa_path, scheme)?;
2093
2094 fs::remove_dir_all(&payload_dir).map_err(|e| {
2096 BenchError::Build(format!(
2097 "Failed to clean up Payload dir at {}: {}. Check file permissions.",
2098 payload_dir.display(),
2099 e
2100 ))
2101 })?;
2102
2103 println!("✓ IPA created: {:?}", ipa_path);
2104 Ok(ipa_path)
2105 }
2106
2107 pub fn package_xcuitest(&self, scheme: &str) -> Result<PathBuf, BenchError> {
2112 let ios_dir = self.output_dir.join("ios").join(scheme);
2113 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
2114
2115 if !project_path.exists() {
2116 return Err(BenchError::Build(format!(
2117 "Xcode project not found at {}.\n\n\
2118 Run `cargo mobench build --target ios` first or check --output-dir.",
2119 project_path.display()
2120 )));
2121 }
2122
2123 let export_path = self.output_dir.join("ios");
2124 fs::create_dir_all(&export_path).map_err(|e| {
2125 BenchError::Build(format!(
2126 "Failed to create export directory at {}: {}. Check output directory permissions.",
2127 export_path.display(),
2128 e
2129 ))
2130 })?;
2131
2132 let build_dir = self.output_dir.join("ios/build");
2133 println!("Building XCUITest runner for {}...", scheme);
2134
2135 let mut cmd = Command::new("xcodebuild");
2136 cmd.arg("build-for-testing")
2137 .arg("-project")
2138 .arg(&project_path)
2139 .arg("-scheme")
2140 .arg(scheme)
2141 .arg("-destination")
2142 .arg("generic/platform=iOS")
2143 .arg("-sdk")
2144 .arg("iphoneos")
2145 .arg("-configuration")
2146 .arg("Release")
2147 .arg("-derivedDataPath")
2148 .arg(&build_dir)
2149 .arg("VALIDATE_PRODUCT=NO")
2150 .arg("CODE_SIGN_STYLE=Manual")
2151 .arg("CODE_SIGN_IDENTITY=")
2152 .arg("CODE_SIGNING_ALLOWED=NO")
2153 .arg("CODE_SIGNING_REQUIRED=NO")
2154 .arg("DEVELOPMENT_TEAM=")
2155 .arg("PROVISIONING_PROFILE_SPECIFIER=")
2156 .arg("ENABLE_BITCODE=NO")
2157 .arg("BITCODE_GENERATION_MODE=none")
2158 .arg("STRIP_BITCODE_FROM_COPIED_FILES=NO");
2159
2160 if self.verbose {
2161 println!(" Running: {:?}", cmd);
2162 }
2163
2164 let runner_name = format!("{}UITests-Runner.app", scheme);
2165 let runner_path = build_dir
2166 .join("Build/Products/Release-iphoneos")
2167 .join(&runner_name);
2168
2169 let build_result = cmd.output();
2170 let log_path = export_path.join("xcuitest-build.log");
2171 if let Ok(output) = &build_result
2172 && !output.status.success()
2173 {
2174 let mut log = String::new();
2175 let stdout = String::from_utf8_lossy(&output.stdout);
2176 let stderr = String::from_utf8_lossy(&output.stderr);
2177 log.push_str("STDOUT:\n");
2178 log.push_str(&stdout);
2179 log.push_str("\n\nSTDERR:\n");
2180 log.push_str(&stderr);
2181 let _ = fs::write(&log_path, log);
2182 println!("xcodebuild log written to {:?}", log_path);
2183 if runner_path.exists() {
2184 println!(
2185 "Warning: xcodebuild build-for-testing failed, but runner exists: {}",
2186 stderr
2187 );
2188 }
2189 }
2190
2191 if !runner_path.exists() {
2192 match build_result {
2193 Ok(output) => {
2194 let stdout = String::from_utf8_lossy(&output.stdout);
2195 let stderr = String::from_utf8_lossy(&output.stderr);
2196 return Err(BenchError::Build(format!(
2197 "xcodebuild build-for-testing failed and runner was not created.\n\n\
2198 Project: {}\n\
2199 Scheme: {}\n\
2200 Derived data: {}\n\
2201 Exit status: {}\n\
2202 Log: {}\n\n\
2203 Stdout:\n{}\n\n\
2204 Stderr:\n{}\n\n\
2205 Tip: open the log file above for more context.",
2206 project_path.display(),
2207 scheme,
2208 build_dir.display(),
2209 output.status,
2210 log_path.display(),
2211 stdout,
2212 stderr
2213 )));
2214 }
2215 Err(err) => {
2216 return Err(BenchError::Build(format!(
2217 "Failed to run xcodebuild: {}.\n\n\
2218 XCUITest runner not found at {}.\n\
2219 Check that Xcode command line tools are installed.",
2220 err,
2221 runner_path.display()
2222 )));
2223 }
2224 }
2225 }
2226
2227 let profile = find_provisioning_profile();
2228 let identity = find_codesign_identity();
2229 if let (Some(profile), Some(identity)) = (profile.as_ref(), identity.as_ref()) {
2230 embed_provisioning_profile(&runner_path, profile)?;
2231 codesign_bundle(&runner_path, identity)?;
2232 if self.verbose {
2233 println!(" Signed XCUITest runner with identity {}", identity);
2234 }
2235 } else {
2236 println!(
2237 "Warning: No provisioning profile/identity found; XCUITest runner may not install."
2238 );
2239 }
2240
2241 let zip_path = export_path.join(format!("{}UITests.zip", scheme));
2242 if zip_path.exists() {
2243 fs::remove_file(&zip_path).map_err(|e| {
2244 BenchError::Build(format!(
2245 "Failed to remove old zip at {}: {}. Check file permissions.",
2246 zip_path.display(),
2247 e
2248 ))
2249 })?;
2250 }
2251
2252 let runner_parent = runner_path.parent().ok_or_else(|| {
2253 BenchError::Build(format!(
2254 "Invalid XCUITest runner path with no parent directory: {}",
2255 runner_path.display()
2256 ))
2257 })?;
2258
2259 let mut zip_cmd = Command::new("zip");
2260 zip_cmd
2261 .arg("-qr")
2262 .arg(&zip_path)
2263 .arg(&runner_name)
2264 .current_dir(runner_parent);
2265
2266 if self.verbose {
2267 println!(" Running: {:?}", zip_cmd);
2268 }
2269
2270 run_command(zip_cmd, "zip XCUITest runner")?;
2271 println!("✓ XCUITest runner packaged: {:?}", zip_path);
2272
2273 Ok(zip_path)
2274 }
2275
2276 fn copy_bundle_with_ditto(&self, src: &Path, dest: &Path) -> Result<(), BenchError> {
2277 let mut cmd = Command::new("ditto");
2278 cmd.arg(src).arg(dest);
2279
2280 if self.verbose {
2281 println!(" Running: {:?}", cmd);
2282 }
2283
2284 run_command(cmd, "copy app bundle with ditto")
2285 }
2286
2287 fn ensure_device_app_bundle_metadata(
2288 &self,
2289 app_path: &Path,
2290 source_info_plist: &Path,
2291 scheme: &str,
2292 ) -> Result<(), BenchError> {
2293 let bundled_info_plist = app_path.join("Info.plist");
2294 if !bundled_info_plist.is_file() {
2295 if !source_info_plist.is_file() {
2296 return Err(BenchError::Build(format!(
2297 "Built app bundle at {} is missing Info.plist, and the generated source plist was not found at {}.\n\n\
2298 The device build produced an incomplete .app bundle, so packaging cannot continue.",
2299 app_path.display(),
2300 source_info_plist.display()
2301 )));
2302 }
2303
2304 fs::copy(source_info_plist, &bundled_info_plist).map_err(|e| {
2305 BenchError::Build(format!(
2306 "Built app bundle at {} is missing Info.plist, and restoring it from {} failed: {}.",
2307 app_path.display(),
2308 source_info_plist.display(),
2309 e
2310 ))
2311 })?;
2312 println!(
2313 "Warning: Restored missing Info.plist into built app bundle from {}.",
2314 source_info_plist.display()
2315 );
2316 }
2317
2318 let executable = app_path.join(scheme);
2319 if !executable.is_file() {
2320 return Err(BenchError::Build(format!(
2321 "Built app bundle at {} is missing the expected executable {}.\n\n\
2322 The device build produced an incomplete .app bundle, so packaging cannot continue.",
2323 app_path.display(),
2324 executable.display()
2325 )));
2326 }
2327
2328 Ok(())
2329 }
2330
2331 fn validate_ipa_archive(&self, ipa_path: &Path, scheme: &str) -> Result<(), BenchError> {
2332 let extract_root = env::temp_dir().join(format!(
2333 "mobench-ipa-validate-{}-{}",
2334 std::process::id(),
2335 SystemTime::now()
2336 .duration_since(UNIX_EPOCH)
2337 .map(|d| d.as_nanos())
2338 .unwrap_or(0)
2339 ));
2340
2341 if extract_root.exists() {
2342 fs::remove_dir_all(&extract_root).map_err(|e| {
2343 BenchError::Build(format!(
2344 "Failed to clear IPA validation dir at {}: {}",
2345 extract_root.display(),
2346 e
2347 ))
2348 })?;
2349 }
2350 fs::create_dir_all(&extract_root).map_err(|e| {
2351 BenchError::Build(format!(
2352 "Failed to create IPA validation dir at {}: {}",
2353 extract_root.display(),
2354 e
2355 ))
2356 })?;
2357
2358 let mut extract = Command::new("ditto");
2359 extract.arg("-x").arg("-k").arg(ipa_path).arg(&extract_root);
2360
2361 let extract_result = run_command(extract, "extract IPA for validation");
2362 if let Err(err) = extract_result {
2363 let _ = fs::remove_dir_all(&extract_root);
2364 return Err(err);
2365 }
2366
2367 let info_plist = extract_root
2368 .join("Payload")
2369 .join(format!("{}.app", scheme))
2370 .join("Info.plist");
2371 let validation_result = if info_plist.is_file() {
2372 Ok(())
2373 } else {
2374 Err(BenchError::Build(format!(
2375 "IPA validation failed: {} is missing from {}.\n\n\
2376 The packaged IPA does not contain a valid iOS app bundle. \
2377 BrowserStack will reject this upload.",
2378 info_plist
2379 .strip_prefix(&extract_root)
2380 .unwrap_or(&info_plist)
2381 .display(),
2382 ipa_path.display()
2383 )))
2384 };
2385
2386 let _ = fs::remove_dir_all(&extract_root);
2387 validation_result
2388 }
2389}
2390
2391#[cfg(test)]
2392mod tests {
2393 use super::*;
2394 #[cfg(target_os = "macos")]
2395 use std::io::Write;
2396
2397 #[test]
2398 fn test_ios_builder_creation() {
2399 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2400 assert!(!builder.verbose);
2401 assert_eq!(
2402 builder.output_dir,
2403 PathBuf::from("/tmp/test-project/target/mobench")
2404 );
2405 }
2406
2407 #[test]
2408 fn test_ios_builder_verbose() {
2409 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
2410 assert!(builder.verbose);
2411 }
2412
2413 #[test]
2414 fn test_ios_builder_custom_output_dir() {
2415 let builder =
2416 IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output");
2417 assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
2418 }
2419
2420 #[test]
2421 fn test_boltffi_swift_bindings_filename_uses_crate_name() {
2422 assert_eq!(
2423 boltffi_swift_bindings_filename("ffi-benchmark"),
2424 "Ffi-benchmarkBoltFFI.swift"
2425 );
2426 assert_eq!(
2427 boltffi_swift_bindings_filename("sample_fns"),
2428 "Sample_fnsBoltFFI.swift"
2429 );
2430 assert_eq!(
2431 boltffi_swift_bindings_path_fragment("ffi-benchmark"),
2432 PathBuf::from("BoltFFI/Ffi-benchmarkBoltFFI.swift")
2433 );
2434 }
2435
2436 #[cfg(target_os = "macos")]
2437 #[test]
2438 fn test_validate_ipa_archive_rejects_missing_info_plist() {
2439 let temp_dir = env::temp_dir().join(format!(
2440 "mobench-ios-test-bad-ipa-{}-{}",
2441 std::process::id(),
2442 SystemTime::now()
2443 .duration_since(UNIX_EPOCH)
2444 .map(|d| d.as_nanos())
2445 .unwrap_or(0)
2446 ));
2447 let payload = temp_dir.join("Payload/BenchRunner.app");
2448 fs::create_dir_all(&payload).expect("create payload");
2449 let ipa = temp_dir.join("broken.ipa");
2450
2451 let status = Command::new("ditto")
2452 .arg("-c")
2453 .arg("-k")
2454 .arg("--sequesterRsrc")
2455 .arg("--keepParent")
2456 .arg("Payload")
2457 .arg(&ipa)
2458 .current_dir(&temp_dir)
2459 .status()
2460 .expect("run ditto");
2461 assert!(status.success(), "ditto should create the broken test ipa");
2462
2463 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2464 let err = builder
2465 .validate_ipa_archive(&ipa, "BenchRunner")
2466 .expect_err("IPA missing Info.plist should be rejected");
2467 assert!(
2468 err.to_string().contains("Info.plist"),
2469 "expected validation error mentioning Info.plist, got: {err}"
2470 );
2471
2472 let _ = fs::remove_dir_all(&temp_dir);
2473 }
2474
2475 #[cfg(target_os = "macos")]
2476 #[test]
2477 fn test_validate_ipa_archive_accepts_payload_with_info_plist() {
2478 let temp_dir = env::temp_dir().join(format!(
2479 "mobench-ios-test-good-ipa-{}-{}",
2480 std::process::id(),
2481 SystemTime::now()
2482 .duration_since(UNIX_EPOCH)
2483 .map(|d| d.as_nanos())
2484 .unwrap_or(0)
2485 ));
2486 let payload = temp_dir.join("Payload/BenchRunner.app");
2487 fs::create_dir_all(&payload).expect("create payload");
2488 let mut info = fs::File::create(payload.join("Info.plist")).expect("create plist");
2489 writeln!(
2490 info,
2491 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>"
2492 )
2493 .expect("write plist");
2494 let ipa = temp_dir.join("valid.ipa");
2495
2496 let status = Command::new("ditto")
2497 .arg("-c")
2498 .arg("-k")
2499 .arg("--sequesterRsrc")
2500 .arg("--keepParent")
2501 .arg("Payload")
2502 .arg(&ipa)
2503 .current_dir(&temp_dir)
2504 .status()
2505 .expect("run ditto");
2506 assert!(status.success(), "ditto should create the valid test ipa");
2507
2508 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2509 builder
2510 .validate_ipa_archive(&ipa, "BenchRunner")
2511 .expect("IPA with Info.plist should validate");
2512
2513 let _ = fs::remove_dir_all(&temp_dir);
2514 }
2515
2516 #[test]
2517 fn test_ensure_device_app_bundle_metadata_restores_missing_info_plist() {
2518 let temp_dir = env::temp_dir().join(format!(
2519 "mobench-ios-test-repair-plist-{}-{}",
2520 std::process::id(),
2521 SystemTime::now()
2522 .duration_since(UNIX_EPOCH)
2523 .map(|d| d.as_nanos())
2524 .unwrap_or(0)
2525 ));
2526 let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
2527 fs::create_dir_all(&app_dir).expect("create app dir");
2528 fs::write(app_dir.join("BenchRunner"), "bin").expect("create executable");
2529
2530 let source_dir = temp_dir.join("BenchRunner");
2531 fs::create_dir_all(&source_dir).expect("create source dir");
2532 fs::write(
2533 source_dir.join("Info.plist"),
2534 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2535 )
2536 .expect("create source plist");
2537
2538 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2539 builder
2540 .ensure_device_app_bundle_metadata(
2541 &app_dir,
2542 &source_dir.join("Info.plist"),
2543 "BenchRunner",
2544 )
2545 .expect("missing plist should be restored");
2546
2547 assert!(
2548 app_dir.join("Info.plist").is_file(),
2549 "restored app bundle should contain Info.plist"
2550 );
2551
2552 let _ = fs::remove_dir_all(&temp_dir);
2553 }
2554
2555 #[test]
2556 fn test_ensure_device_app_bundle_metadata_rejects_missing_executable() {
2557 let temp_dir = env::temp_dir().join(format!(
2558 "mobench-ios-test-missing-exec-{}-{}",
2559 std::process::id(),
2560 SystemTime::now()
2561 .duration_since(UNIX_EPOCH)
2562 .map(|d| d.as_nanos())
2563 .unwrap_or(0)
2564 ));
2565 let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
2566 fs::create_dir_all(&app_dir).expect("create app dir");
2567 fs::write(
2568 app_dir.join("Info.plist"),
2569 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2570 )
2571 .expect("create bundled plist");
2572 let source_dir = temp_dir.join("BenchRunner");
2573 fs::create_dir_all(&source_dir).expect("create source dir");
2574 fs::write(
2575 source_dir.join("Info.plist"),
2576 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2577 )
2578 .expect("create source plist");
2579
2580 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2581 let err = builder
2582 .ensure_device_app_bundle_metadata(
2583 &app_dir,
2584 &source_dir.join("Info.plist"),
2585 "BenchRunner",
2586 )
2587 .expect_err("missing executable should fail validation");
2588 assert!(
2589 err.to_string().contains("missing the expected executable"),
2590 "expected executable validation error, got: {err}"
2591 );
2592
2593 let _ = fs::remove_dir_all(&temp_dir);
2594 }
2595
2596 #[test]
2597 fn test_find_crate_dir_current_directory_is_crate() {
2598 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-current");
2600 let _ = std::fs::remove_dir_all(&temp_dir);
2601 std::fs::create_dir_all(&temp_dir).unwrap();
2602
2603 std::fs::write(
2605 temp_dir.join("Cargo.toml"),
2606 r#"[package]
2607name = "bench-mobile"
2608version = "0.1.0"
2609"#,
2610 )
2611 .unwrap();
2612
2613 let builder = IosBuilder::new(&temp_dir, "bench-mobile");
2614 let result = builder.find_crate_dir();
2615 assert!(result.is_ok(), "Should find crate in current directory");
2616 let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone());
2618 assert_eq!(result.unwrap(), expected);
2619
2620 std::fs::remove_dir_all(&temp_dir).unwrap();
2621 }
2622
2623 #[test]
2624 fn test_find_crate_dir_nested_bench_mobile() {
2625 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-nested");
2627 let _ = std::fs::remove_dir_all(&temp_dir);
2628 std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
2629
2630 std::fs::write(
2632 temp_dir.join("Cargo.toml"),
2633 r#"[workspace]
2634members = ["bench-mobile"]
2635"#,
2636 )
2637 .unwrap();
2638
2639 std::fs::write(
2641 temp_dir.join("bench-mobile/Cargo.toml"),
2642 r#"[package]
2643name = "bench-mobile"
2644version = "0.1.0"
2645"#,
2646 )
2647 .unwrap();
2648
2649 let builder = IosBuilder::new(&temp_dir, "bench-mobile");
2650 let result = builder.find_crate_dir();
2651 assert!(
2652 result.is_ok(),
2653 "Should find crate in bench-mobile/ directory"
2654 );
2655 let expected = temp_dir
2656 .canonicalize()
2657 .unwrap_or(temp_dir.clone())
2658 .join("bench-mobile");
2659 assert_eq!(result.unwrap(), expected);
2660
2661 std::fs::remove_dir_all(&temp_dir).unwrap();
2662 }
2663
2664 #[test]
2665 fn test_find_crate_dir_crates_subdir() {
2666 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-crates");
2668 let _ = std::fs::remove_dir_all(&temp_dir);
2669 std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
2670
2671 std::fs::write(
2673 temp_dir.join("Cargo.toml"),
2674 r#"[workspace]
2675members = ["crates/*"]
2676"#,
2677 )
2678 .unwrap();
2679
2680 std::fs::write(
2682 temp_dir.join("crates/my-bench/Cargo.toml"),
2683 r#"[package]
2684name = "my-bench"
2685version = "0.1.0"
2686"#,
2687 )
2688 .unwrap();
2689
2690 let builder = IosBuilder::new(&temp_dir, "my-bench");
2691 let result = builder.find_crate_dir();
2692 assert!(result.is_ok(), "Should find crate in crates/ directory");
2693 let expected = temp_dir
2694 .canonicalize()
2695 .unwrap_or(temp_dir.clone())
2696 .join("crates/my-bench");
2697 assert_eq!(result.unwrap(), expected);
2698
2699 std::fs::remove_dir_all(&temp_dir).unwrap();
2700 }
2701
2702 #[test]
2703 fn test_find_crate_dir_not_found() {
2704 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-notfound");
2706 let _ = std::fs::remove_dir_all(&temp_dir);
2707 std::fs::create_dir_all(&temp_dir).unwrap();
2708
2709 std::fs::write(
2711 temp_dir.join("Cargo.toml"),
2712 r#"[package]
2713name = "some-other-crate"
2714version = "0.1.0"
2715"#,
2716 )
2717 .unwrap();
2718
2719 let builder = IosBuilder::new(&temp_dir, "nonexistent-crate");
2720 let result = builder.find_crate_dir();
2721 assert!(result.is_err(), "Should fail to find nonexistent crate");
2722 let err_msg = result.unwrap_err().to_string();
2723 assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
2724 assert!(err_msg.contains("Searched locations"));
2725
2726 std::fs::remove_dir_all(&temp_dir).unwrap();
2727 }
2728
2729 #[test]
2730 fn test_find_crate_dir_explicit_crate_path() {
2731 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-explicit");
2733 let _ = std::fs::remove_dir_all(&temp_dir);
2734 std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
2735
2736 let builder =
2737 IosBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
2738 let result = builder.find_crate_dir();
2739 assert!(result.is_ok(), "Should use explicit crate_dir");
2740 assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
2741
2742 std::fs::remove_dir_all(&temp_dir).unwrap();
2743 }
2744}