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