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