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