1use crate::types::{BenchError, InitConfig, Target};
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10
11use include_dir::{Dir, DirEntry, include_dir};
12
13const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android");
14const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios");
15const NATIVE_ANDROID_MAIN_ACTIVITY_TEMPLATE: &str =
16 include_str!("native_templates/android/MainActivity.kt.template");
17const NATIVE_IOS_BENCH_RUNNER_FFI_TEMPLATE: &str =
18 include_str!("native_templates/ios/BenchRunnerFFI.swift.template");
19const BOLTFFI_ANDROID_MAIN_ACTIVITY_TEMPLATE: &str =
20 include_str!("boltffi_templates/android/MainActivity.kt.template");
21const BOLTFFI_IOS_BENCH_RUNNER_FFI_TEMPLATE: &str =
22 include_str!("boltffi_templates/ios/BenchRunnerFFI.swift.template");
23pub const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
24const DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS: u64 = 1800;
25const DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS: u64 = 10;
26
27pub const DEFAULT_IOS_DEPLOYMENT_TARGET: &str = "15.0";
28pub const SWIFTUI_RUNNER_MIN_IOS: &str = "15.0";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum IosRunner {
33 Swiftui,
35 UikitLegacy,
37}
38
39impl IosRunner {
40 pub fn parse(value: &str) -> Result<Self, BenchError> {
41 match value.trim().to_ascii_lowercase().as_str() {
42 "swiftui" => Ok(Self::Swiftui),
43 "uikit-legacy" | "uikit_legacy" => Ok(Self::UikitLegacy),
44 other => Err(BenchError::Build(format!(
45 "Unsupported iOS runner `{other}`. Supported values: swiftui, uikit-legacy"
46 ))),
47 }
48 }
49
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::Swiftui => "swiftui",
53 Self::UikitLegacy => "uikit-legacy",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Eq)]
60pub struct IosDeploymentTarget {
61 raw: String,
62 major: u16,
63 minor: u16,
64}
65
66impl PartialEq for IosDeploymentTarget {
67 fn eq(&self, other: &Self) -> bool {
68 (self.major, self.minor) == (other.major, other.minor)
69 }
70}
71
72impl Ord for IosDeploymentTarget {
73 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
74 (self.major, self.minor).cmp(&(other.major, other.minor))
75 }
76}
77
78impl PartialOrd for IosDeploymentTarget {
79 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
80 Some(self.cmp(other))
81 }
82}
83
84impl IosDeploymentTarget {
85 pub fn parse(raw: &str) -> Result<Self, BenchError> {
86 let raw = raw.trim();
87 if raw.is_empty() {
88 return Err(BenchError::Build(
89 "iOS deployment target must not be empty".to_string(),
90 ));
91 }
92
93 let mut parts = raw.split('.');
94 let major_raw = parts.next().unwrap_or_default();
95 let minor_raw = parts.next().unwrap_or("0");
96 if parts.next().is_some() {
97 return Err(BenchError::Build(format!(
98 "Invalid iOS deployment target `{raw}`. Expected VERSION like 15.0"
99 )));
100 }
101
102 Ok(Self {
103 raw: raw.to_string(),
104 major: parse_ios_version_part(raw, major_raw, "major")?,
105 minor: parse_ios_version_part(raw, minor_raw, "minor")?,
106 })
107 }
108
109 pub fn default_target() -> Self {
110 Self::parse(DEFAULT_IOS_DEPLOYMENT_TARGET)
111 .expect("default iOS deployment target should be valid")
112 }
113}
114
115fn parse_ios_version_part(raw: &str, part: &str, label: &str) -> Result<u16, BenchError> {
116 if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
117 return Err(BenchError::Build(format!(
118 "Invalid iOS deployment target `{raw}`: {label} version component must be numeric"
119 )));
120 }
121
122 part.parse::<u16>().map_err(|err| {
123 BenchError::Build(format!(
124 "Invalid iOS deployment target `{raw}`: failed to parse {label} component: {err}"
125 ))
126 })
127}
128
129impl std::fmt::Display for IosDeploymentTarget {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.write_str(&self.raw)
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct IosProjectOptions {
138 pub deployment_target: IosDeploymentTarget,
139 pub runner: IosRunner,
140 pub ios_benchmark_timeout_secs: u64,
141}
142
143impl Default for IosProjectOptions {
144 fn default() -> Self {
145 Self {
146 deployment_target: IosDeploymentTarget::default_target(),
147 runner: IosRunner::Swiftui,
148 ios_benchmark_timeout_secs: DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS,
149 }
150 }
151}
152
153pub fn resolve_ios_runner(
154 deployment_target: &IosDeploymentTarget,
155 requested_runner: Option<IosRunner>,
156) -> Result<IosRunner, BenchError> {
157 let swiftui_floor = IosDeploymentTarget::parse(SWIFTUI_RUNNER_MIN_IOS)?;
158 match requested_runner {
159 Some(IosRunner::Swiftui) if deployment_target < &swiftui_floor => {
160 Err(BenchError::Build(format!(
161 "iOS runner `swiftui` requires deployment target {SWIFTUI_RUNNER_MIN_IOS}+; \
162 requested deployment target is {deployment_target}. Use `uikit-legacy` for older iOS targets."
163 )))
164 }
165 Some(runner) => Ok(runner),
166 None if deployment_target < &swiftui_floor => Ok(IosRunner::UikitLegacy),
167 None => Ok(IosRunner::Swiftui),
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct TemplateVar {
174 pub name: &'static str,
175 pub value: String,
176}
177
178pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
195 let output_dir = &config.output_dir;
196 let project_slug = sanitize_package_name(&config.project_name);
197 let project_pascal = to_pascal_case(&project_slug);
198 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
200 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
201
202 fs::create_dir_all(output_dir)?;
204
205 generate_bench_mobile_crate(output_dir, &project_slug)?;
207
208 let default_function = "example_fibonacci";
211
212 match config.target {
214 Target::Android => {
215 generate_android_project(output_dir, &project_slug, default_function)?;
216 }
217 Target::Ios => {
218 generate_ios_project(
219 output_dir,
220 &project_slug,
221 &project_pascal,
222 &bundle_prefix,
223 default_function,
224 )?;
225 }
226 Target::Both => {
227 generate_android_project(output_dir, &project_slug, default_function)?;
228 generate_ios_project(
229 output_dir,
230 &project_slug,
231 &project_pascal,
232 &bundle_prefix,
233 default_function,
234 )?;
235 }
236 }
237
238 generate_config_file(output_dir, config)?;
240
241 if config.generate_examples {
243 generate_example_benchmarks(output_dir)?;
244 }
245
246 Ok(output_dir.clone())
247}
248
249fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
251 let crate_dir = output_dir.join("bench-mobile");
252 fs::create_dir_all(crate_dir.join("src"))?;
253
254 let crate_name = format!("{}-bench-mobile", project_name);
255
256 let cargo_toml = format!(
260 r#"[package]
261name = "{}"
262version = "0.1.0"
263edition = "2021"
264
265[lib]
266crate-type = ["cdylib", "staticlib", "rlib"]
267
268[dependencies]
269mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
270uniffi = "0.28"
271{} = {{ path = ".." }}
272
273[features]
274default = []
275
276[build-dependencies]
277uniffi = {{ version = "0.28", features = ["build"] }}
278
279# Binary for generating UniFFI bindings (used by mobench build)
280[[bin]]
281name = "uniffi-bindgen"
282path = "src/bin/uniffi-bindgen.rs"
283
284# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
285# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
286# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
287#
288# Add this to your root Cargo.toml:
289# [workspace.dependencies]
290# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
291#
292# Then in each crate that uses rustls:
293# [dependencies]
294# rustls = {{ workspace = true }}
295"#,
296 crate_name, project_name
297 );
298
299 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
300
301 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
303//!
304//! This crate provides the FFI boundary between Rust benchmarks and mobile
305//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
306
307use uniffi;
308
309// Ensure the user crate is linked so benchmark registrations are pulled in.
310extern crate {{USER_CRATE}} as _bench_user_crate;
311
312// Re-export mobench-sdk types with UniFFI annotations
313#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
314pub struct BenchSpec {
315 pub name: String,
316 pub iterations: u32,
317 pub warmup: u32,
318}
319
320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
321pub struct BenchSample {
322 pub duration_ns: u64,
323 pub cpu_time_ms: Option<u64>,
324 pub peak_memory_kb: Option<u64>,
325 pub process_peak_memory_kb: Option<u64>,
326}
327
328#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
329pub struct SemanticPhase {
330 pub name: String,
331 pub duration_ns: u64,
332}
333
334#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
335pub struct HarnessTimelineSpan {
336 pub phase: String,
337 pub start_offset_ns: u64,
338 pub end_offset_ns: u64,
339 pub iteration: Option<u32>,
340}
341
342#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
343pub struct BenchReport {
344 pub spec: BenchSpec,
345 pub samples: Vec<BenchSample>,
346 pub phases: Vec<SemanticPhase>,
347 pub timeline: Vec<HarnessTimelineSpan>,
348}
349
350#[derive(Debug, thiserror::Error, uniffi::Error)]
351#[uniffi(flat_error)]
352pub enum BenchError {
353 #[error("iterations must be greater than zero")]
354 InvalidIterations,
355
356 #[error("unknown benchmark function: {name}")]
357 UnknownFunction { name: String },
358
359 #[error("benchmark execution failed: {reason}")]
360 ExecutionFailed { reason: String },
361}
362
363// Convert from mobench-sdk types
364impl From<mobench_sdk::BenchSpec> for BenchSpec {
365 fn from(spec: mobench_sdk::BenchSpec) -> Self {
366 Self {
367 name: spec.name,
368 iterations: spec.iterations,
369 warmup: spec.warmup,
370 }
371 }
372}
373
374impl From<BenchSpec> for mobench_sdk::BenchSpec {
375 fn from(spec: BenchSpec) -> Self {
376 Self {
377 name: spec.name,
378 iterations: spec.iterations,
379 warmup: spec.warmup,
380 }
381 }
382}
383
384impl From<mobench_sdk::BenchSample> for BenchSample {
385 fn from(sample: mobench_sdk::BenchSample) -> Self {
386 Self {
387 duration_ns: sample.duration_ns,
388 cpu_time_ms: sample.cpu_time_ms,
389 peak_memory_kb: sample.peak_memory_kb,
390 process_peak_memory_kb: sample.process_peak_memory_kb,
391 }
392 }
393}
394
395impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
396 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
397 Self {
398 name: phase.name,
399 duration_ns: phase.duration_ns,
400 }
401 }
402}
403
404impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
405 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
406 Self {
407 phase: span.phase,
408 start_offset_ns: span.start_offset_ns,
409 end_offset_ns: span.end_offset_ns,
410 iteration: span.iteration,
411 }
412 }
413}
414
415impl From<mobench_sdk::RunnerReport> for BenchReport {
416 fn from(report: mobench_sdk::RunnerReport) -> Self {
417 Self {
418 spec: report.spec.into(),
419 samples: report.samples.into_iter().map(Into::into).collect(),
420 phases: report.phases.into_iter().map(Into::into).collect(),
421 timeline: report.timeline.into_iter().map(Into::into).collect(),
422 }
423 }
424}
425
426impl From<mobench_sdk::BenchError> for BenchError {
427 fn from(err: mobench_sdk::BenchError) -> Self {
428 match err {
429 mobench_sdk::BenchError::Runner(runner_err) => {
430 BenchError::ExecutionFailed {
431 reason: runner_err.to_string(),
432 }
433 }
434 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
435 BenchError::UnknownFunction { name }
436 }
437 _ => BenchError::ExecutionFailed {
438 reason: err.to_string(),
439 },
440 }
441 }
442}
443
444/// Runs a benchmark by name with the given specification
445///
446/// This is the main FFI entry point called from mobile platforms.
447#[uniffi::export]
448pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
449 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
450 let report = mobench_sdk::run_benchmark(sdk_spec)?;
451 Ok(report.into())
452}
453
454// Generate UniFFI scaffolding
455uniffi::setup_scaffolding!();
456"#;
457
458 let lib_rs = render_template(
459 lib_rs_template,
460 &[TemplateVar {
461 name: "USER_CRATE",
462 value: project_name.replace('-', "_"),
463 }],
464 );
465 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
466
467 let build_rs = r#"fn main() {
469 uniffi::generate_scaffolding("src/lib.rs").unwrap();
470}
471"#;
472
473 fs::write(crate_dir.join("build.rs"), build_rs)?;
474
475 let bin_dir = crate_dir.join("src/bin");
477 fs::create_dir_all(&bin_dir)?;
478 let uniffi_bindgen_rs = r#"fn main() {
479 uniffi::uniffi_bindgen_main()
480}
481"#;
482 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
483
484 Ok(())
485}
486
487pub fn generate_android_project(
498 output_dir: &Path,
499 project_slug: &str,
500 default_function: &str,
501) -> Result<(), BenchError> {
502 generate_android_project_with_backend(
503 output_dir,
504 project_slug,
505 default_function,
506 crate::FfiBackend::Uniffi,
507 )
508}
509
510pub fn generate_android_project_with_backend(
512 output_dir: &Path,
513 project_slug: &str,
514 default_function: &str,
515 ffi_backend: crate::FfiBackend,
516) -> Result<(), BenchError> {
517 let android_benchmark_timeout_secs = resolve_positive_u64_env(
518 "MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS",
519 DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS,
520 );
521 let android_heartbeat_interval_secs = resolve_positive_u64_env(
522 "MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS",
523 DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
524 );
525 generate_android_project_with_options(
526 output_dir,
527 project_slug,
528 default_function,
529 ffi_backend,
530 android_benchmark_timeout_secs,
531 android_heartbeat_interval_secs,
532 )
533}
534
535fn generate_android_project_with_options(
536 output_dir: &Path,
537 project_slug: &str,
538 default_function: &str,
539 ffi_backend: crate::FfiBackend,
540 android_benchmark_timeout_secs: u64,
541 android_heartbeat_interval_secs: u64,
542) -> Result<(), BenchError> {
543 let target_dir = output_dir.join("android");
544 reset_generated_project_dir(&target_dir)?;
545 let library_name = project_slug.replace('-', "_");
546 let project_pascal = to_pascal_case(project_slug);
547 let package_id_component = sanitize_bundle_id_component(project_slug);
550 let package_name = format!("dev.world.{}", package_id_component);
551 let boltffi_kotlin_package = format!("com.mobench.{}", package_id_component);
552 let vars = vec![
553 TemplateVar {
554 name: "PROJECT_NAME",
555 value: project_slug.to_string(),
556 },
557 TemplateVar {
558 name: "PROJECT_NAME_PASCAL",
559 value: project_pascal.clone(),
560 },
561 TemplateVar {
562 name: "APP_NAME",
563 value: format!("{} Benchmark", project_pascal),
564 },
565 TemplateVar {
566 name: "PACKAGE_NAME",
567 value: package_name.clone(),
568 },
569 TemplateVar {
570 name: "BOLTFFI_KOTLIN_PACKAGE",
571 value: boltffi_kotlin_package.clone(),
572 },
573 TemplateVar {
574 name: "UNIFFI_NAMESPACE",
575 value: library_name.clone(),
576 },
577 TemplateVar {
578 name: "LIBRARY_NAME",
579 value: library_name,
580 },
581 TemplateVar {
582 name: "DEFAULT_FUNCTION",
583 value: default_function.to_string(),
584 },
585 TemplateVar {
586 name: "ANDROID_BENCHMARK_TIMEOUT_SECS",
587 value: android_benchmark_timeout_secs.to_string(),
588 },
589 TemplateVar {
590 name: "ANDROID_HEARTBEAT_INTERVAL_SECS",
591 value: android_heartbeat_interval_secs.to_string(),
592 },
593 ];
594 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
595
596 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
599 match ffi_backend {
600 crate::FfiBackend::Uniffi => {}
601 crate::FfiBackend::NativeCAbi => {
602 write_native_android_main_activity(&target_dir, &package_name, &vars)?;
603 }
604 crate::FfiBackend::BoltFfi => {
605 write_boltffi_android_main_activity(&target_dir, &package_name, &vars)?;
606 write_boltffi_config(
607 output_dir,
608 project_slug,
609 "BenchRunner",
610 &boltffi_kotlin_package,
611 )?;
612 }
613 }
614
615 Ok(())
616}
617
618fn write_native_android_main_activity(
619 target_dir: &Path,
620 package_name: &str,
621 vars: &[TemplateVar],
622) -> Result<(), BenchError> {
623 let relative_package = package_name.replace('.', "/");
624 let path = target_dir
625 .join("app/src/main/java")
626 .join(relative_package)
627 .join("MainActivity.kt");
628 let rendered = render_template(NATIVE_ANDROID_MAIN_ACTIVITY_TEMPLATE, vars);
629 validate_no_unreplaced_placeholders(&rendered, Path::new("MainActivity.kt"))?;
630 fs::write(&path, rendered).map_err(BenchError::Io)
631}
632
633fn write_boltffi_android_main_activity(
634 target_dir: &Path,
635 package_name: &str,
636 vars: &[TemplateVar],
637) -> Result<(), BenchError> {
638 let relative_package = package_name.replace('.', "/");
639 let path = target_dir
640 .join("app/src/main/java")
641 .join(relative_package)
642 .join("MainActivity.kt");
643 let rendered = render_template(BOLTFFI_ANDROID_MAIN_ACTIVITY_TEMPLATE, vars);
644 validate_no_unreplaced_placeholders(&rendered, Path::new("MainActivity.kt"))?;
645 fs::write(&path, rendered).map_err(BenchError::Io)
646}
647
648fn collect_preserved_files(
649 root: &Path,
650 current: &Path,
651 preserved: &mut Vec<(PathBuf, Vec<u8>)>,
652) -> Result<(), BenchError> {
653 let mut entries = fs::read_dir(current)?
654 .collect::<Result<Vec<_>, _>>()
655 .map_err(BenchError::Io)?;
656 entries.sort_by_key(|entry| entry.path());
657
658 for entry in entries {
659 let path = entry.path();
660 if path.is_dir() {
661 collect_preserved_files(root, &path, preserved)?;
662 continue;
663 }
664
665 let relative = path.strip_prefix(root).map_err(|e| {
666 BenchError::Build(format!(
667 "Failed to preserve generated resource {:?}: {}",
668 path, e
669 ))
670 })?;
671 preserved.push((relative.to_path_buf(), fs::read(&path)?));
672 }
673
674 Ok(())
675}
676
677fn collect_preserved_ios_resources(
678 target_dir: &Path,
679) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
680 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
681 let mut preserved = Vec::new();
682
683 if resources_dir.exists() {
684 collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
685 }
686
687 Ok(preserved)
688}
689
690fn restore_preserved_ios_resources(
691 target_dir: &Path,
692 preserved_resources: &[(PathBuf, Vec<u8>)],
693) -> Result<(), BenchError> {
694 if preserved_resources.is_empty() {
695 return Ok(());
696 }
697
698 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
699 for (relative, contents) in preserved_resources {
700 let resource_path = resources_dir.join(relative);
701 if let Some(parent) = resource_path.parent() {
702 fs::create_dir_all(parent)?;
703 }
704 fs::write(resource_path, contents)?;
705 }
706
707 Ok(())
708}
709
710fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
711 if target_dir.exists() {
712 fs::remove_dir_all(target_dir).map_err(|e| {
713 BenchError::Build(format!(
714 "Failed to clear existing generated project at {:?}: {}",
715 target_dir, e
716 ))
717 })?;
718 }
719 Ok(())
720}
721
722fn move_kotlin_files_to_package_dir(
732 android_dir: &Path,
733 package_name: &str,
734) -> Result<(), BenchError> {
735 let package_path = package_name.replace('.', "/");
737
738 let main_java_dir = android_dir.join("app/src/main/java");
740 let main_package_dir = main_java_dir.join(&package_path);
741 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
742
743 let test_java_dir = android_dir.join("app/src/androidTest/java");
745 let test_package_dir = test_java_dir.join(&package_path);
746 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
747
748 Ok(())
749}
750
751fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
753 let src_file = src_dir.join(filename);
754 if !src_file.exists() {
755 return Ok(());
757 }
758
759 fs::create_dir_all(dest_dir).map_err(|e| {
761 BenchError::Build(format!(
762 "Failed to create package directory {:?}: {}",
763 dest_dir, e
764 ))
765 })?;
766
767 let dest_file = dest_dir.join(filename);
768
769 fs::copy(&src_file, &dest_file).map_err(|e| {
771 BenchError::Build(format!(
772 "Failed to copy {} to {:?}: {}",
773 filename, dest_file, e
774 ))
775 })?;
776
777 fs::remove_file(&src_file).map_err(|e| {
778 BenchError::Build(format!(
779 "Failed to remove original file {:?}: {}",
780 src_file, e
781 ))
782 })?;
783
784 Ok(())
785}
786
787pub fn generate_ios_project(
800 output_dir: &Path,
801 project_slug: &str,
802 project_pascal: &str,
803 bundle_prefix: &str,
804 default_function: &str,
805) -> Result<(), BenchError> {
806 generate_ios_project_with_backend_options(
807 output_dir,
808 project_slug,
809 project_pascal,
810 bundle_prefix,
811 default_function,
812 crate::FfiBackend::Uniffi,
813 IosProjectOptions {
814 ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs(
815 std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
816 .ok()
817 .as_deref(),
818 ),
819 ..IosProjectOptions::default()
820 },
821 )
822}
823
824pub fn generate_ios_project_with_backend(
826 output_dir: &Path,
827 project_slug: &str,
828 project_pascal: &str,
829 bundle_prefix: &str,
830 default_function: &str,
831 ffi_backend: crate::FfiBackend,
832) -> Result<(), BenchError> {
833 generate_ios_project_with_backend_options(
834 output_dir,
835 project_slug,
836 project_pascal,
837 bundle_prefix,
838 default_function,
839 ffi_backend,
840 IosProjectOptions {
841 ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs(
842 std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
843 .ok()
844 .as_deref(),
845 ),
846 ..IosProjectOptions::default()
847 },
848 )
849}
850
851#[cfg(test)]
852fn generate_ios_project_with_timeout(
853 output_dir: &Path,
854 project_slug: &str,
855 project_pascal: &str,
856 bundle_prefix: &str,
857 default_function: &str,
858 ios_benchmark_timeout_secs: u64,
859 ffi_backend: crate::FfiBackend,
860) -> Result<(), BenchError> {
861 generate_ios_project_with_backend_options(
862 output_dir,
863 project_slug,
864 project_pascal,
865 bundle_prefix,
866 default_function,
867 ffi_backend,
868 IosProjectOptions {
869 ios_benchmark_timeout_secs,
870 ..IosProjectOptions::default()
871 },
872 )
873}
874
875pub fn generate_ios_project_with_backend_options(
876 output_dir: &Path,
877 project_slug: &str,
878 project_pascal: &str,
879 bundle_prefix: &str,
880 default_function: &str,
881 ffi_backend: crate::FfiBackend,
882 options: IosProjectOptions,
883) -> Result<(), BenchError> {
884 let runner = resolve_ios_runner(&options.deployment_target, Some(options.runner))?;
885 let target_dir = output_dir.join("ios");
886 let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
887 reset_generated_project_dir(&target_dir)?;
888 let sanitized_bundle_prefix = {
891 let parts: Vec<&str> = bundle_prefix.split('.').collect();
892 parts
893 .iter()
894 .map(|part| sanitize_bundle_id_component(part))
895 .collect::<Vec<_>>()
896 .join(".")
897 };
898 let vars = vec![
902 TemplateVar {
903 name: "DEFAULT_FUNCTION",
904 value: default_function.to_string(),
905 },
906 TemplateVar {
907 name: "PROJECT_NAME_PASCAL",
908 value: project_pascal.to_string(),
909 },
910 TemplateVar {
911 name: "BUNDLE_ID_PREFIX",
912 value: sanitized_bundle_prefix.clone(),
913 },
914 TemplateVar {
915 name: "BUNDLE_ID",
916 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
917 },
918 TemplateVar {
919 name: "LIBRARY_NAME",
920 value: project_slug.replace('-', "_"),
921 },
922 TemplateVar {
923 name: "IOS_BENCHMARK_TIMEOUT_SECS",
924 value: options.ios_benchmark_timeout_secs.to_string(),
925 },
926 TemplateVar {
927 name: "IOS_DEPLOYMENT_TARGET",
928 value: options.deployment_target.to_string(),
929 },
930 TemplateVar {
931 name: "IOS_RUNNER",
932 value: runner.as_str().to_string(),
933 },
934 TemplateVar {
935 name: "BRIDGING_HEADER_IMPORTS",
936 value: match ffi_backend {
937 crate::FfiBackend::BoltFfi => String::new(),
938 _ => format!("#import \"{}FFI.h\"", project_slug.replace('-', "_")),
939 },
940 },
941 ];
942 render_ios_dir(&IOS_TEMPLATES, &target_dir, &vars, runner)?;
943 match ffi_backend {
944 crate::FfiBackend::Uniffi => {}
945 crate::FfiBackend::NativeCAbi => {
946 write_native_ios_bench_runner_ffi(&target_dir, project_pascal, &vars)?;
947 }
948 crate::FfiBackend::BoltFfi => {
949 write_boltffi_ios_bench_runner_ffi(&target_dir, project_pascal, &vars)?;
950 write_boltffi_config(
951 output_dir,
952 project_slug,
953 project_pascal,
954 "dev.world.benchrunner",
955 )?;
956 }
957 }
958 restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
959 Ok(())
960}
961
962fn write_native_ios_bench_runner_ffi(
963 target_dir: &Path,
964 project_pascal: &str,
965 vars: &[TemplateVar],
966) -> Result<(), BenchError> {
967 let app_dir = target_dir.join("BenchRunner").join(project_pascal);
968 let path = app_dir.join("BenchRunnerFFI.swift");
969 let generated_dir = app_dir.join("Generated");
970 fs::create_dir_all(&generated_dir).map_err(BenchError::Io)?;
971 let library_name = vars
972 .iter()
973 .find(|var| var.name == "LIBRARY_NAME")
974 .map(|var| var.value.as_str())
975 .unwrap_or("bench_mobile");
976 let header_path = generated_dir.join(format!("{library_name}FFI.h"));
977 fs::write(&header_path, native_c_abi_header(library_name)).map_err(BenchError::Io)?;
978
979 let rendered = render_template(NATIVE_IOS_BENCH_RUNNER_FFI_TEMPLATE, vars);
980 validate_no_unreplaced_placeholders(&rendered, Path::new("BenchRunnerFFI.swift"))?;
981 fs::write(&path, rendered).map_err(BenchError::Io)
982}
983
984fn write_boltffi_ios_bench_runner_ffi(
985 target_dir: &Path,
986 project_pascal: &str,
987 vars: &[TemplateVar],
988) -> Result<(), BenchError> {
989 let app_dir = target_dir.join("BenchRunner").join(project_pascal);
990 let path = app_dir.join("BenchRunnerFFI.swift");
991 let generated_dir = app_dir.join("Generated").join("BoltFFIGenerated");
992 fs::create_dir_all(&generated_dir).map_err(BenchError::Io)?;
993
994 let rendered = render_template(BOLTFFI_IOS_BENCH_RUNNER_FFI_TEMPLATE, vars);
995 validate_no_unreplaced_placeholders(&rendered, Path::new("BenchRunnerFFI.swift"))?;
996 fs::write(&path, rendered).map_err(BenchError::Io)
997}
998
999pub fn write_boltffi_config(
1000 project_root: &Path,
1001 library_name: &str,
1002 swift_module_name: &str,
1003 kotlin_package: &str,
1004) -> Result<(), BenchError> {
1005 write_boltffi_config_with_options(
1006 project_root,
1007 library_name,
1008 library_name,
1009 swift_module_name,
1010 kotlin_package,
1011 &["arm64".to_string()],
1012 )
1013}
1014
1015pub fn write_boltffi_config_with_options(
1016 project_root: &Path,
1017 package_name: &str,
1018 crate_name: &str,
1019 swift_module_name: &str,
1020 kotlin_package: &str,
1021 android_architectures: &[String],
1022) -> Result<(), BenchError> {
1023 write_boltffi_config_with_paths(
1024 project_root,
1025 package_name,
1026 crate_name,
1027 swift_module_name,
1028 kotlin_package,
1029 android_architectures,
1030 &BoltFfiOutputPaths::default(),
1031 )
1032}
1033
1034pub fn write_boltffi_config_with_output_dir(
1035 project_root: &Path,
1036 package_name: &str,
1037 crate_name: &str,
1038 swift_module_name: &str,
1039 kotlin_package: &str,
1040 android_architectures: &[String],
1041 output_dir: &Path,
1042) -> Result<(), BenchError> {
1043 write_boltffi_config_with_paths(
1044 project_root,
1045 package_name,
1046 crate_name,
1047 swift_module_name,
1048 kotlin_package,
1049 android_architectures,
1050 &BoltFfiOutputPaths::for_output_dir(output_dir),
1051 )
1052}
1053
1054struct BoltFfiOutputPaths {
1055 android_kotlin: String,
1056 android_header: String,
1057 android_pack: String,
1058 apple_swift: String,
1059 apple_header: String,
1060 apple_xcframework: String,
1061}
1062
1063impl BoltFfiOutputPaths {
1064 fn default() -> Self {
1065 Self {
1066 android_kotlin: "target/mobench/android/app/src/main/java".to_string(),
1067 android_header: "target/mobench/boltffi/android/include".to_string(),
1068 android_pack: "target/mobench/android/app/src/main/jniLibs".to_string(),
1069 apple_swift: "target/mobench/ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated"
1070 .to_string(),
1071 apple_header: "target/mobench/ios/include".to_string(),
1072 apple_xcframework: "target/mobench/ios".to_string(),
1073 }
1074 }
1075
1076 fn for_output_dir(output_dir: &Path) -> Self {
1077 Self {
1078 android_kotlin: toml_path(output_dir.join("android/app/src/main/java")),
1079 android_header: toml_path(output_dir.join("boltffi/android/include")),
1080 android_pack: toml_path(output_dir.join("android/app/src/main/jniLibs")),
1081 apple_swift: toml_path(
1082 output_dir.join("ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated"),
1083 ),
1084 apple_header: toml_path(output_dir.join("ios/include")),
1085 apple_xcframework: toml_path(output_dir.join("ios")),
1086 }
1087 }
1088}
1089
1090fn toml_path(path: PathBuf) -> String {
1091 path.to_string_lossy().replace('\\', "\\\\")
1092}
1093
1094fn write_boltffi_config_with_paths(
1095 project_root: &Path,
1096 package_name: &str,
1097 crate_name: &str,
1098 swift_module_name: &str,
1099 kotlin_package: &str,
1100 android_architectures: &[String],
1101 paths: &BoltFfiOutputPaths,
1102) -> Result<(), BenchError> {
1103 let library_name = crate_name.replace('-', "_");
1104 let android_architectures = android_architectures
1105 .iter()
1106 .map(|arch| format!("\"{arch}\""))
1107 .collect::<Vec<_>>()
1108 .join(", ");
1109 let content = format!(
1110 r#"[package]
1111name = "{package_name}"
1112crate = "{crate_name}"
1113
1114[targets.android]
1115enabled = true
1116architectures = [{android_architectures}]
1117
1118[targets.android.kotlin]
1119package = "{kotlin_package}"
1120output = "{android_kotlin}"
1121library_name = "{library_name}"
1122desktop_loader = "system"
1123api_style = "top_level"
1124
1125[targets.android.header]
1126output = "{android_header}"
1127
1128[targets.android.pack]
1129output = "{android_pack}"
1130
1131[targets.apple]
1132enabled = true
1133include_macos = false
1134
1135[targets.apple.swift]
1136module_name = "{swift_module_name}"
1137output = "{apple_swift}"
1138ffi_module_name = "{library_name}FFI"
1139
1140[targets.apple.header]
1141output = "{apple_header}"
1142
1143[targets.apple.xcframework]
1144output = "{apple_xcframework}"
1145name = "{library_name}"
1146
1147[targets.apple.spm]
1148layout = "split"
1149skip_package_swift = true
1150"#,
1151 android_kotlin = paths.android_kotlin,
1152 android_header = paths.android_header,
1153 android_pack = paths.android_pack,
1154 apple_swift = paths.apple_swift,
1155 apple_header = paths.apple_header,
1156 apple_xcframework = paths.apple_xcframework,
1157 );
1158 fs::write(project_root.join("boltffi.toml"), content).map_err(BenchError::Io)
1159}
1160
1161fn native_c_abi_header(framework_name: &str) -> String {
1162 let guard = format!(
1163 "{}_MOBENCH_NATIVE_C_ABI_H",
1164 framework_name.to_ascii_uppercase()
1165 )
1166 .replace('-', "_");
1167 format!(
1168 r#"#ifndef {guard}
1169#define {guard}
1170
1171#include <stdint.h>
1172#include <stddef.h>
1173
1174#ifdef __cplusplus
1175extern "C" {{
1176#endif
1177
1178typedef struct MobenchBuf {{
1179 uint8_t *ptr;
1180 uintptr_t len;
1181 uintptr_t cap;
1182}} MobenchBuf;
1183
1184int32_t mobench_run_benchmark_json(const uint8_t *spec_ptr, uintptr_t spec_len, MobenchBuf *out);
1185void mobench_free_buf(MobenchBuf *buf);
1186const char *mobench_last_error_message(void);
1187
1188#ifdef __cplusplus
1189}}
1190#endif
1191
1192#endif
1193"#
1194 )
1195}
1196
1197fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
1198 value
1199 .and_then(|raw| raw.parse::<u64>().ok())
1200 .filter(|secs| *secs > 0)
1201 .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
1202}
1203
1204fn resolve_positive_u64_env(name: &str, default: u64) -> u64 {
1205 std::env::var(name)
1206 .ok()
1207 .and_then(|raw| raw.parse::<u64>().ok())
1208 .filter(|secs| *secs > 0)
1209 .unwrap_or(default)
1210}
1211
1212fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
1214 let config_target = match config.target {
1215 Target::Ios => "ios",
1216 Target::Android | Target::Both => "android",
1217 };
1218 let config_content = format!(
1219 r#"# mobench configuration
1220# This file controls how benchmarks are executed on devices.
1221
1222target = "{}"
1223function = "example_fibonacci"
1224iterations = 100
1225warmup = 10
1226device_matrix = "device-matrix.yaml"
1227device_tags = ["default"]
1228
1229[browserstack]
1230app_automate_username = "${{BROWSERSTACK_USERNAME}}"
1231app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
1232project = "{}-benchmarks"
1233
1234[ios_xcuitest]
1235app = "target/ios/BenchRunner.ipa"
1236test_suite = "target/ios/BenchRunnerUITests.zip"
1237"#,
1238 config_target, config.project_name
1239 );
1240
1241 fs::write(output_dir.join("bench-config.toml"), config_content)?;
1242
1243 Ok(())
1244}
1245
1246fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
1248 let examples_dir = output_dir.join("benches");
1249 fs::create_dir_all(&examples_dir)?;
1250
1251 let example_content = r#"//! Example benchmarks
1252//!
1253//! This file demonstrates how to write benchmarks with mobench-sdk.
1254
1255use mobench_sdk::benchmark;
1256
1257/// Simple benchmark example
1258#[benchmark]
1259fn example_fibonacci() {
1260 let result = fibonacci(30);
1261 std::hint::black_box(result);
1262}
1263
1264/// Another example with a loop
1265#[benchmark]
1266fn example_sum() {
1267 let mut sum = 0u64;
1268 for i in 0..10000 {
1269 sum = sum.wrapping_add(i);
1270 }
1271 std::hint::black_box(sum);
1272}
1273
1274// Helper function (not benchmarked)
1275fn fibonacci(n: u32) -> u64 {
1276 match n {
1277 0 => 0,
1278 1 => 1,
1279 _ => {
1280 let mut a = 0u64;
1281 let mut b = 1u64;
1282 for _ in 2..=n {
1283 let next = a.wrapping_add(b);
1284 a = b;
1285 b = next;
1286 }
1287 b
1288 }
1289 }
1290}
1291"#;
1292
1293 fs::write(examples_dir.join("example.rs"), example_content)?;
1294
1295 Ok(())
1296}
1297
1298const TEMPLATE_EXTENSIONS: &[&str] = &[
1300 "gradle",
1301 "xml",
1302 "kt",
1303 "java",
1304 "swift",
1305 "yml",
1306 "yaml",
1307 "json",
1308 "toml",
1309 "md",
1310 "txt",
1311 "h",
1312 "m",
1313 "plist",
1314 "pbxproj",
1315 "xcscheme",
1316 "xcworkspacedata",
1317 "entitlements",
1318 "modulemap",
1319];
1320
1321fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
1322 render_dir_filtered(dir, out_root, vars, &|_| false)
1323}
1324
1325fn render_ios_dir(
1326 dir: &Dir,
1327 out_root: &Path,
1328 vars: &[TemplateVar],
1329 runner: IosRunner,
1330) -> Result<(), BenchError> {
1331 render_dir_filtered(dir, out_root, vars, &|path| match runner {
1332 IosRunner::Swiftui => {
1333 path == Path::new("BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template")
1334 }
1335 IosRunner::UikitLegacy => {
1336 path == Path::new("BenchRunner/BenchRunner/BenchRunnerApp.swift.template")
1337 || path == Path::new("BenchRunner/BenchRunner/ContentView.swift.template")
1338 }
1339 })
1340}
1341
1342fn render_dir_filtered(
1343 dir: &Dir,
1344 out_root: &Path,
1345 vars: &[TemplateVar],
1346 skip_file: &dyn Fn(&Path) -> bool,
1347) -> Result<(), BenchError> {
1348 for entry in dir.entries() {
1349 match entry {
1350 DirEntry::Dir(sub) => {
1351 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
1353 continue;
1354 }
1355 render_dir_filtered(sub, out_root, vars, skip_file)?;
1356 }
1357 DirEntry::File(file) => {
1358 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
1359 continue;
1360 }
1361 if skip_file(file.path()) {
1362 continue;
1363 }
1364 let mut relative = file.path().to_path_buf();
1366 let mut contents = file.contents().to_vec();
1367
1368 let is_explicit_template = relative
1370 .extension()
1371 .map(|ext| ext == "template")
1372 .unwrap_or(false);
1373
1374 let should_render = is_explicit_template || is_template_file(&relative);
1376
1377 if is_explicit_template {
1378 relative.set_extension("");
1380 }
1381
1382 if should_render && let Ok(text) = std::str::from_utf8(&contents) {
1383 let rendered = render_template(text, vars);
1384 validate_no_unreplaced_placeholders(&rendered, &relative)?;
1386 contents = rendered.into_bytes();
1387 }
1388
1389 let out_path = out_root.join(relative);
1390 if let Some(parent) = out_path.parent() {
1391 fs::create_dir_all(parent)?;
1392 }
1393 fs::write(&out_path, contents)?;
1394 }
1395 }
1396 }
1397 Ok(())
1398}
1399
1400fn is_template_file(path: &Path) -> bool {
1403 if let Some(ext) = path.extension() {
1405 if ext == "template" {
1406 return true;
1407 }
1408 if let Some(ext_str) = ext.to_str() {
1410 return TEMPLATE_EXTENSIONS.contains(&ext_str);
1411 }
1412 }
1413 if let Some(stem) = path.file_stem() {
1415 let stem_path = Path::new(stem);
1416 if let Some(ext) = stem_path.extension()
1417 && let Some(ext_str) = ext.to_str()
1418 {
1419 return TEMPLATE_EXTENSIONS.contains(&ext_str);
1420 }
1421 }
1422 false
1423}
1424
1425fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
1427 let mut pos = 0;
1429 let mut unreplaced = Vec::new();
1430
1431 while let Some(start) = content[pos..].find("{{") {
1432 let abs_start = pos + start;
1433 if let Some(end) = content[abs_start..].find("}}") {
1434 let placeholder = &content[abs_start..abs_start + end + 2];
1435 let var_name = &content[abs_start + 2..abs_start + end];
1437 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
1440 unreplaced.push(placeholder.to_string());
1441 }
1442 pos = abs_start + end + 2;
1443 } else {
1444 break;
1445 }
1446 }
1447
1448 if !unreplaced.is_empty() {
1449 return Err(BenchError::Build(format!(
1450 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
1451 This is a bug in mobench-sdk. Please report it at:\n\
1452 https://github.com/worldcoin/mobile-bench-rs/issues",
1453 file_path, unreplaced
1454 )));
1455 }
1456
1457 Ok(())
1458}
1459
1460fn render_template(input: &str, vars: &[TemplateVar]) -> String {
1461 let mut output = input.to_string();
1462 for var in vars {
1463 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
1464 }
1465 output
1466}
1467
1468pub fn sanitize_bundle_id_component(name: &str) -> String {
1479 name.chars()
1480 .filter(|c| c.is_ascii_alphanumeric())
1481 .collect::<String>()
1482 .to_lowercase()
1483}
1484
1485fn sanitize_package_name(name: &str) -> String {
1486 name.chars()
1487 .map(|c| {
1488 if c.is_ascii_alphanumeric() {
1489 c.to_ascii_lowercase()
1490 } else {
1491 '-'
1492 }
1493 })
1494 .collect::<String>()
1495 .trim_matches('-')
1496 .replace("--", "-")
1497}
1498
1499pub fn to_pascal_case(input: &str) -> String {
1501 input
1502 .split(|c: char| !c.is_ascii_alphanumeric())
1503 .filter(|s| !s.is_empty())
1504 .map(|s| {
1505 let mut chars = s.chars();
1506 let first = chars.next().unwrap().to_ascii_uppercase();
1507 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
1508 format!("{}{}", first, rest)
1509 })
1510 .collect::<String>()
1511}
1512
1513pub fn android_project_exists(output_dir: &Path) -> bool {
1517 let android_dir = output_dir.join("android");
1518 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
1519}
1520
1521pub fn ios_project_exists(output_dir: &Path) -> bool {
1525 output_dir.join("ios/BenchRunner/project.yml").exists()
1526}
1527
1528fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1533 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
1534 let Ok(content) = std::fs::read_to_string(&project_yml) else {
1535 return false;
1536 };
1537 let expected = format!("../{}.xcframework", library_name);
1538 content.contains(&expected)
1539}
1540
1541fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1546 let build_gradle = output_dir.join("android/app/build.gradle");
1547 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
1548 return false;
1549 };
1550 let expected = format!("lib{}.so", library_name);
1551 content.contains(&expected)
1552}
1553
1554fn android_project_matches_backend(output_dir: &Path, ffi_backend: crate::FfiBackend) -> bool {
1556 let Some(main_activity) = read_android_main_activity(output_dir) else {
1557 return false;
1558 };
1559
1560 match ffi_backend {
1561 crate::FfiBackend::Uniffi => {
1562 main_activity.contains("uniffi.") || main_activity.contains("runBenchmark(spec")
1563 }
1564 crate::FfiBackend::NativeCAbi => {
1565 main_activity.contains("mobench_run_benchmark_json")
1566 && main_activity.contains("com.sun.jna")
1567 }
1568 crate::FfiBackend::BoltFfi => main_activity.contains("runBenchmarkJson(specJson"),
1569 }
1570}
1571
1572fn read_android_main_activity(output_dir: &Path) -> Option<String> {
1573 let java_root = output_dir.join("android/app/src/main/java");
1574 let mut stack = vec![java_root];
1575 let mut content = String::new();
1576
1577 while let Some(dir) = stack.pop() {
1578 let entries = std::fs::read_dir(dir).ok()?;
1579 for entry in entries.flatten() {
1580 let path = entry.path();
1581 if path.is_dir() {
1582 stack.push(path);
1583 } else if path.file_name().and_then(|name| name.to_str()) == Some("MainActivity.kt") {
1584 let file_content = std::fs::read_to_string(path).ok()?;
1585 content.push_str(&file_content);
1586 content.push('\n');
1587 }
1588 }
1589 }
1590
1591 if content.is_empty() {
1592 None
1593 } else {
1594 Some(content)
1595 }
1596}
1597
1598pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
1613 let lib_rs = crate_dir.join("src/lib.rs");
1614 if !lib_rs.exists() {
1615 return None;
1616 }
1617
1618 let file = fs::File::open(&lib_rs).ok()?;
1619 let reader = BufReader::new(file);
1620
1621 let mut found_benchmark_attr = false;
1622 let crate_name_normalized = crate_name.replace('-', "_");
1623
1624 for line in reader.lines().map_while(Result::ok) {
1625 let trimmed = line.trim();
1626
1627 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1629 found_benchmark_attr = true;
1630 continue;
1631 }
1632
1633 if found_benchmark_attr {
1635 if let Some(fn_pos) = trimmed.find("fn ") {
1637 let after_fn = &trimmed[fn_pos + 3..];
1638 let fn_name: String = after_fn
1640 .chars()
1641 .take_while(|c| c.is_alphanumeric() || *c == '_')
1642 .collect();
1643
1644 if !fn_name.is_empty() {
1645 return Some(format!("{}::{}", crate_name_normalized, fn_name));
1646 }
1647 }
1648 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1651 found_benchmark_attr = false;
1652 }
1653 }
1654 }
1655
1656 None
1657}
1658
1659pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1673 let lib_rs = crate_dir.join("src/lib.rs");
1674 if !lib_rs.exists() {
1675 return Vec::new();
1676 }
1677
1678 let Ok(file) = fs::File::open(&lib_rs) else {
1679 return Vec::new();
1680 };
1681 let reader = BufReader::new(file);
1682
1683 let mut benchmarks = Vec::new();
1684 let mut found_benchmark_attr = false;
1685 let crate_name_normalized = crate_name.replace('-', "_");
1686
1687 for line in reader.lines().map_while(Result::ok) {
1688 let trimmed = line.trim();
1689
1690 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1692 found_benchmark_attr = true;
1693 continue;
1694 }
1695
1696 if found_benchmark_attr {
1698 if let Some(fn_pos) = trimmed.find("fn ") {
1700 let after_fn = &trimmed[fn_pos + 3..];
1701 let fn_name: String = after_fn
1703 .chars()
1704 .take_while(|c| c.is_alphanumeric() || *c == '_')
1705 .collect();
1706
1707 if !fn_name.is_empty() {
1708 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1709 }
1710 found_benchmark_attr = false;
1711 }
1712 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1715 found_benchmark_attr = false;
1716 }
1717 }
1718 }
1719
1720 benchmarks
1721}
1722
1723pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1735 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1736 let crate_name_normalized = crate_name.replace('-', "_");
1737
1738 let normalized_name = if function_name.contains("::") {
1740 function_name.to_string()
1741 } else {
1742 format!("{}::{}", crate_name_normalized, function_name)
1743 };
1744
1745 benchmarks.iter().any(|b| b == &normalized_name)
1746}
1747
1748pub fn resolve_default_function(
1763 project_root: &Path,
1764 crate_name: &str,
1765 crate_dir: Option<&Path>,
1766) -> String {
1767 let crate_name_normalized = crate_name.replace('-', "_");
1768
1769 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1771 vec![dir.to_path_buf()]
1772 } else {
1773 vec![
1774 project_root.join("bench-mobile"),
1775 project_root.join("crates").join(crate_name),
1776 project_root.to_path_buf(),
1777 ]
1778 };
1779
1780 for dir in &search_dirs {
1782 if dir.join("Cargo.toml").exists()
1783 && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1784 {
1785 return detected;
1786 }
1787 }
1788
1789 format!("{}::example_benchmark", crate_name_normalized)
1791}
1792
1793pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1804 ensure_android_project_with_options(output_dir, crate_name, None, None)
1805}
1806
1807pub fn ensure_android_project_with_options(
1819 output_dir: &Path,
1820 crate_name: &str,
1821 project_root: Option<&Path>,
1822 crate_dir: Option<&Path>,
1823) -> Result<(), BenchError> {
1824 ensure_android_project_with_backend_options(
1825 output_dir,
1826 crate_name,
1827 project_root,
1828 crate_dir,
1829 crate::FfiBackend::Uniffi,
1830 )
1831}
1832
1833pub fn ensure_android_project_with_backend_options(
1835 output_dir: &Path,
1836 crate_name: &str,
1837 project_root: Option<&Path>,
1838 crate_dir: Option<&Path>,
1839 ffi_backend: crate::FfiBackend,
1840) -> Result<(), BenchError> {
1841 let library_name = crate_name.replace('-', "_");
1842 let project_exists = android_project_exists(output_dir);
1843 let project_matches_library = android_project_matches_library(output_dir, &library_name);
1844 let project_matches_backend = android_project_matches_backend(output_dir, ffi_backend);
1845
1846 if project_exists && !project_matches_library {
1847 println!(
1848 "Existing Android scaffolding does not match library, regenerating for {} backend...",
1849 ffi_backend
1850 );
1851 } else if project_exists && !project_matches_backend {
1852 println!(
1853 "Existing Android scaffolding does not match FFI backend, regenerating for {} backend...",
1854 ffi_backend
1855 );
1856 } else if project_exists {
1857 println!(
1858 "Refreshing generated Android scaffolding for {} backend...",
1859 ffi_backend
1860 );
1861 } else {
1862 println!(
1863 "Android project not found, generating scaffolding for {} backend...",
1864 ffi_backend
1865 );
1866 }
1867
1868 let project_slug = crate_name.replace('-', "_");
1869
1870 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1872 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1873
1874 generate_android_project_with_backend(
1875 output_dir,
1876 &project_slug,
1877 &default_function,
1878 ffi_backend,
1879 )?;
1880 println!(
1881 " Generated Android project at {:?}",
1882 output_dir.join("android")
1883 );
1884 println!(" Default benchmark function: {}", default_function);
1885 Ok(())
1886}
1887
1888pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1899 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1900}
1901
1902pub fn ensure_ios_project_with_options(
1914 output_dir: &Path,
1915 crate_name: &str,
1916 project_root: Option<&Path>,
1917 crate_dir: Option<&Path>,
1918) -> Result<(), BenchError> {
1919 ensure_ios_project_with_backend_options(
1920 output_dir,
1921 crate_name,
1922 project_root,
1923 crate_dir,
1924 crate::FfiBackend::Uniffi,
1925 IosProjectOptions::default(),
1926 )
1927}
1928
1929pub fn ensure_ios_project_with_backend_options(
1931 output_dir: &Path,
1932 crate_name: &str,
1933 project_root: Option<&Path>,
1934 crate_dir: Option<&Path>,
1935 ffi_backend: crate::FfiBackend,
1936 options: IosProjectOptions,
1937) -> Result<(), BenchError> {
1938 let library_name = crate_name.replace('-', "_");
1939 let project_exists = ios_project_exists(output_dir);
1940 let project_matches = ios_project_matches_library(output_dir, &library_name);
1941 if project_exists && !project_matches {
1942 println!(
1943 "Existing iOS scaffolding does not match library, regenerating for {} backend...",
1944 ffi_backend
1945 );
1946 } else if project_exists {
1947 println!(
1948 "Refreshing generated iOS scaffolding for {} backend...",
1949 ffi_backend
1950 );
1951 } else {
1952 println!(
1953 "iOS project not found, generating scaffolding for {} backend...",
1954 ffi_backend
1955 );
1956 }
1957
1958 let project_pascal = "BenchRunner";
1960 let library_name = crate_name.replace('-', "_");
1962 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1965 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1966
1967 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1969 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1970
1971 generate_ios_project_with_backend_options(
1972 output_dir,
1973 &library_name,
1974 project_pascal,
1975 &bundle_prefix,
1976 &default_function,
1977 ffi_backend,
1978 options,
1979 )?;
1980 sync_ios_mobile_spec(output_dir)?;
1981 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1982 println!(" Default benchmark function: {}", default_function);
1983 Ok(())
1984}
1985
1986fn sync_ios_mobile_spec(output_dir: &Path) -> Result<(), BenchError> {
1987 let source = output_dir.join("target/mobile-spec/ios/bench_spec.json");
1988 if !source.is_file() {
1989 return Ok(());
1990 }
1991 let destination = output_dir.join("ios/BenchRunner/BenchRunner/Resources/bench_spec.json");
1992 let parent = destination.parent().ok_or_else(|| {
1993 BenchError::Build(format!(
1994 "Invalid iOS benchmark spec destination: {}",
1995 destination.display()
1996 ))
1997 })?;
1998 fs::create_dir_all(parent).map_err(|error| {
1999 BenchError::Build(format!(
2000 "Failed to create iOS benchmark resource directory {}: {error}",
2001 parent.display()
2002 ))
2003 })?;
2004 fs::copy(&source, &destination).map_err(|error| {
2005 BenchError::Build(format!(
2006 "Failed to copy iOS benchmark spec from {} to {}: {error}",
2007 source.display(),
2008 destination.display()
2009 ))
2010 })?;
2011 Ok(())
2012}
2013
2014#[cfg(test)]
2015mod tests {
2016 use super::*;
2017 use std::env;
2018
2019 #[test]
2020 fn test_generate_bench_mobile_crate() {
2021 let temp_dir = env::temp_dir().join("mobench-sdk-test");
2022 fs::create_dir_all(&temp_dir).unwrap();
2023
2024 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
2025 assert!(result.is_ok());
2026
2027 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
2029 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
2030 assert!(temp_dir.join("bench-mobile/build.rs").exists());
2031 let cargo_toml =
2032 fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
2033 assert!(
2034 cargo_toml.contains(
2035 r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
2036 ),
2037 "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
2038 );
2039
2040 fs::remove_dir_all(&temp_dir).ok();
2042 }
2043
2044 #[test]
2045 fn test_generate_android_project_no_unreplaced_placeholders() {
2046 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
2047 let _ = fs::remove_dir_all(&temp_dir);
2049 fs::create_dir_all(&temp_dir).unwrap();
2050
2051 let result =
2052 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
2053 assert!(
2054 result.is_ok(),
2055 "generate_android_project failed: {:?}",
2056 result.err()
2057 );
2058
2059 let android_dir = temp_dir.join("android");
2061 assert!(android_dir.join("settings.gradle").exists());
2062 assert!(android_dir.join("app/build.gradle").exists());
2063 assert!(
2064 android_dir
2065 .join("app/src/main/AndroidManifest.xml")
2066 .exists()
2067 );
2068 assert!(
2069 android_dir
2070 .join("app/src/main/res/values/strings.xml")
2071 .exists()
2072 );
2073 assert!(
2074 android_dir
2075 .join("app/src/main/res/values/themes.xml")
2076 .exists()
2077 );
2078
2079 let files_to_check = [
2081 "settings.gradle",
2082 "app/build.gradle",
2083 "app/src/main/AndroidManifest.xml",
2084 "app/src/main/res/values/strings.xml",
2085 "app/src/main/res/values/themes.xml",
2086 ];
2087
2088 for file in files_to_check {
2089 let path = android_dir.join(file);
2090 let contents =
2091 fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
2092
2093 let has_placeholder = contents.contains("{{") && contents.contains("}}");
2095 assert!(
2096 !has_placeholder,
2097 "File {} contains unreplaced template placeholders: {}",
2098 file, contents
2099 );
2100 }
2101
2102 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
2104 assert!(
2105 settings.contains("my-bench-project-android")
2106 || settings.contains("my_bench_project-android"),
2107 "settings.gradle should contain project name"
2108 );
2109
2110 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
2111 assert!(
2113 build_gradle.contains("dev.world.mybenchproject"),
2114 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
2115 );
2116 assert!(
2117 !build_gradle.contains("testBuildType \"release\""),
2118 "debug builds should be able to produce assembleDebugAndroidTest"
2119 );
2120 assert!(
2121 build_gradle.contains("mobenchTestBuildType"),
2122 "release builds should be able to request assembleReleaseAndroidTest"
2123 );
2124
2125 let manifest =
2126 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
2127 assert!(
2128 manifest.contains("Theme.MyBenchProject"),
2129 "AndroidManifest.xml should contain PascalCase theme name"
2130 );
2131
2132 let strings =
2133 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
2134 assert!(
2135 strings.contains("Benchmark"),
2136 "strings.xml should contain app name with Benchmark"
2137 );
2138
2139 let main_activity_path =
2142 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
2143 assert!(
2144 main_activity_path.exists(),
2145 "MainActivity.kt should be in package directory: {:?}",
2146 main_activity_path
2147 );
2148
2149 let test_activity_path = android_dir
2150 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
2151 assert!(
2152 test_activity_path.exists(),
2153 "MainActivityTest.kt should be in package directory: {:?}",
2154 test_activity_path
2155 );
2156
2157 assert!(
2159 !android_dir
2160 .join("app/src/main/java/MainActivity.kt")
2161 .exists(),
2162 "MainActivity.kt should not be in root java directory"
2163 );
2164 assert!(
2165 !android_dir
2166 .join("app/src/androidTest/java/MainActivityTest.kt")
2167 .exists(),
2168 "MainActivityTest.kt should not be in root java directory"
2169 );
2170
2171 fs::remove_dir_all(&temp_dir).ok();
2173 }
2174
2175 #[test]
2176 fn test_generate_android_project_replaces_previous_package_tree() {
2177 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
2178 let _ = fs::remove_dir_all(&temp_dir);
2179 fs::create_dir_all(&temp_dir).unwrap();
2180
2181 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
2182 .unwrap();
2183 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
2184 assert!(
2185 old_package_dir.exists(),
2186 "expected first package tree to exist"
2187 );
2188
2189 generate_android_project(
2190 &temp_dir,
2191 "basic_benchmark",
2192 "basic_benchmark::bench_fibonacci",
2193 )
2194 .unwrap();
2195
2196 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
2197 assert!(
2198 new_package_dir.exists(),
2199 "expected new package tree to exist"
2200 );
2201 assert!(
2202 !old_package_dir.exists(),
2203 "old package tree should be removed when regenerating the Android scaffold"
2204 );
2205
2206 fs::remove_dir_all(&temp_dir).ok();
2207 }
2208
2209 #[test]
2210 fn test_generate_android_native_backend_runner_template() {
2211 let temp_dir = env::temp_dir().join("mobench-sdk-android-native-test");
2212 let _ = fs::remove_dir_all(&temp_dir);
2213 fs::create_dir_all(&temp_dir).unwrap();
2214
2215 generate_android_project_with_backend(
2216 &temp_dir,
2217 "native_benchmark",
2218 "native_benchmark::bench_prove",
2219 crate::FfiBackend::NativeCAbi,
2220 )
2221 .unwrap();
2222
2223 let main_activity = fs::read_to_string(
2224 temp_dir.join("android/app/src/main/java/dev/world/nativebenchmark/MainActivity.kt"),
2225 )
2226 .unwrap();
2227 assert!(main_activity.contains("com.sun.jna.Native"));
2228 assert!(main_activity.contains("com.sun.jna.NativeLong"));
2229 assert!(main_activity.contains("mobench_run_benchmark_json"));
2230 assert!(main_activity.contains("specLen: NativeLong"));
2231 assert!(main_activity.contains("@JvmField var len: NativeLong = NativeLong(0)"));
2232 assert!(main_activity.contains("@JvmField var cap: NativeLong = NativeLong(0)"));
2233 assert!(!main_activity.contains("specLen: Long"));
2234 assert!(main_activity.contains("BENCH_JSON"));
2235 assert!(main_activity.contains("bench_spec.json"));
2236 assert!(main_activity.contains("logBenchJson(json.toString())"));
2237 assert!(main_activity.contains("fun benchmarkTimeoutSecs(): Long"));
2238 assert!(main_activity.contains("fun heartbeatIntervalSecs(): Long"));
2239 assert!(main_activity.contains("fun emitTimeoutFailureFromTest(): String"));
2240 assert!(
2241 !main_activity.contains("uniffi."),
2242 "native Android runner must not import UniFFI bindings:\n{}",
2243 main_activity
2244 );
2245 assert!(
2246 !main_activity.contains("runBenchmark("),
2247 "native Android runner must call the JSON C ABI, not UniFFI runBenchmark"
2248 );
2249 let package_manifest =
2250 fs::read_to_string(temp_dir.join("android/app/build.gradle")).unwrap();
2251 assert!(package_manifest.contains("net.java.dev.jna:jna"));
2252 assert!(!package_manifest.contains("uniffi"));
2253 let android_manifest =
2254 fs::read_to_string(temp_dir.join("android/app/src/main/AndroidManifest.xml")).unwrap();
2255 assert!(android_manifest.contains("android:name=\".MainActivity\""));
2256 assert!(!android_manifest.contains("uniffi"));
2257 assert!(!android_manifest.contains("{{"));
2258
2259 fs::remove_dir_all(&temp_dir).ok();
2260 }
2261
2262 #[test]
2263 fn test_generate_android_boltffi_backend_runner_template() {
2264 let temp_dir = env::temp_dir().join("mobench-sdk-android-boltffi-test");
2265 let _ = fs::remove_dir_all(&temp_dir);
2266 fs::create_dir_all(&temp_dir).unwrap();
2267
2268 generate_android_project_with_backend(
2269 &temp_dir,
2270 "bolt_benchmark",
2271 "bolt_benchmark::bench_prove",
2272 crate::FfiBackend::BoltFfi,
2273 )
2274 .unwrap();
2275
2276 let main_activity = fs::read_to_string(
2277 temp_dir.join("android/app/src/main/java/dev/world/boltbenchmark/MainActivity.kt"),
2278 )
2279 .unwrap();
2280 assert!(main_activity.contains("import com.mobench.boltbenchmark.runBenchmarkJson"));
2281 assert!(main_activity.contains("runBenchmarkJson(specJson = spec.toString())"));
2282 assert!(main_activity.contains("fun isBenchmarkComplete()"));
2283 assert!(main_activity.contains("BENCH_JSON"));
2284 assert!(
2285 !main_activity.contains("uniffi."),
2286 "BoltFFI Android runner must not import UniFFI bindings:\n{}",
2287 main_activity
2288 );
2289 assert!(
2290 !main_activity.contains("com.sun.jna"),
2291 "BoltFFI Android runner must not use the native C ABI JNA bridge"
2292 );
2293
2294 let boltffi_toml = fs::read_to_string(temp_dir.join("boltffi.toml")).unwrap();
2295 assert!(boltffi_toml.contains("name = \"bolt_benchmark\""));
2296 assert!(boltffi_toml.contains("crate = \"bolt_benchmark\""));
2297 assert!(boltffi_toml.contains("package = \"com.mobench.boltbenchmark\""));
2298 assert!(boltffi_toml.contains("desktop_loader = \"system\""));
2299 assert!(boltffi_toml.contains("output = \"target/mobench/android/app/src/main/java\""));
2300
2301 fs::remove_dir_all(&temp_dir).ok();
2302 }
2303
2304 #[test]
2305 fn test_ensure_android_project_regenerates_when_ffi_backend_changes() {
2306 let temp_dir = env::temp_dir().join("mobench-sdk-android-backend-switch-test");
2307 let _ = fs::remove_dir_all(&temp_dir);
2308 fs::create_dir_all(&temp_dir).unwrap();
2309
2310 generate_android_project_with_backend(
2311 &temp_dir,
2312 "switch_benchmark",
2313 "switch_benchmark::bench_prove",
2314 crate::FfiBackend::Uniffi,
2315 )
2316 .unwrap();
2317
2318 let main_activity_path =
2319 temp_dir.join("android/app/src/main/java/dev/world/switchbenchmark/MainActivity.kt");
2320 let uniffi_main = fs::read_to_string(&main_activity_path).unwrap();
2321 assert!(uniffi_main.contains("uniffi."));
2322
2323 ensure_android_project_with_backend_options(
2324 &temp_dir,
2325 "switch_benchmark",
2326 None,
2327 None,
2328 crate::FfiBackend::BoltFfi,
2329 )
2330 .unwrap();
2331
2332 let boltffi_main = fs::read_to_string(&main_activity_path).unwrap();
2333 assert!(boltffi_main.contains("runBenchmarkJson(specJson = spec.toString())"));
2334 assert!(
2335 !boltffi_main.contains("uniffi."),
2336 "backend changes should regenerate the Android runner:\n{}",
2337 boltffi_main
2338 );
2339
2340 fs::remove_dir_all(&temp_dir).ok();
2341 }
2342
2343 #[test]
2344 fn test_ensure_android_project_refreshes_existing_backend_scaffolding() {
2345 let temp_dir = env::temp_dir().join("mobench-sdk-android-refresh-test");
2346 let _ = fs::remove_dir_all(&temp_dir);
2347 fs::create_dir_all(&temp_dir).unwrap();
2348
2349 generate_android_project_with_backend(
2350 &temp_dir,
2351 "refresh_benchmark",
2352 "refresh_benchmark::bench_prove",
2353 crate::FfiBackend::BoltFfi,
2354 )
2355 .unwrap();
2356
2357 let main_activity_path =
2358 temp_dir.join("android/app/src/main/java/dev/world/refreshbenchmark/MainActivity.kt");
2359 let stale_main = fs::read_to_string(&main_activity_path)
2360 .unwrap()
2361 .replace("BENCH_JSON_START", "STALE_BENCH_JSON_START");
2362 fs::write(&main_activity_path, stale_main).unwrap();
2363
2364 ensure_android_project_with_backend_options(
2365 &temp_dir,
2366 "refresh_benchmark",
2367 None,
2368 None,
2369 crate::FfiBackend::BoltFfi,
2370 )
2371 .unwrap();
2372
2373 let refreshed_main = fs::read_to_string(&main_activity_path).unwrap();
2374 assert!(refreshed_main.contains("BENCH_JSON_START"));
2375 assert!(
2376 !refreshed_main.contains("STALE_BENCH_JSON_START"),
2377 "existing same-backend scaffolding should be refreshed:\n{}",
2378 refreshed_main
2379 );
2380
2381 fs::remove_dir_all(&temp_dir).ok();
2382 }
2383
2384 #[test]
2385 fn test_write_boltffi_config_can_target_explicit_output_dir() {
2386 let temp_dir = env::temp_dir().join("mobench-sdk-boltffi-config-output-test");
2387 let _ = fs::remove_dir_all(&temp_dir);
2388 fs::create_dir_all(&temp_dir).unwrap();
2389 let output_dir = temp_dir.join("custom-mobench-output");
2390
2391 write_boltffi_config_with_output_dir(
2392 &temp_dir,
2393 "bolt_benchmark",
2394 "bolt-benchmark",
2395 "BenchRunner",
2396 "com.mobench.boltbenchmark",
2397 &["arm64".to_string(), "x86_64".to_string()],
2398 &output_dir,
2399 )
2400 .unwrap();
2401
2402 let boltffi_toml = fs::read_to_string(temp_dir.join("boltffi.toml")).unwrap();
2403 assert!(boltffi_toml.contains(&format!(
2404 "output = \"{}\"",
2405 output_dir
2406 .join("android/app/src/main/java")
2407 .to_string_lossy()
2408 )));
2409 assert!(boltffi_toml.contains(&format!(
2410 "output = \"{}\"",
2411 output_dir
2412 .join("ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated")
2413 .to_string_lossy()
2414 )));
2415
2416 fs::remove_dir_all(&temp_dir).ok();
2417 }
2418
2419 #[test]
2420 fn test_is_template_file() {
2421 assert!(is_template_file(Path::new("settings.gradle")));
2422 assert!(is_template_file(Path::new("app/build.gradle")));
2423 assert!(is_template_file(Path::new("AndroidManifest.xml")));
2424 assert!(is_template_file(Path::new("strings.xml")));
2425 assert!(is_template_file(Path::new("MainActivity.kt.template")));
2426 assert!(is_template_file(Path::new("project.yml")));
2427 assert!(is_template_file(Path::new("Info.plist")));
2428 assert!(!is_template_file(Path::new("libfoo.so")));
2429 assert!(!is_template_file(Path::new("image.png")));
2430 }
2431
2432 #[test]
2433 fn test_mobile_templates_read_process_peak_memory_compatibly() {
2434 let android =
2435 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
2436 assert!(
2437 !android.contains("sample.processPeakMemoryKb"),
2438 "Android template should not require generated bindings to expose processPeakMemoryKb"
2439 );
2440 assert!(
2441 !android.contains("it.processPeakMemoryKb"),
2442 "Android template should not require generated bindings to expose processPeakMemoryKb"
2443 );
2444 assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
2445 assert!(
2446 !android.contains("sample.cpuTimeMs"),
2447 "Android template should tolerate BenchSample without cpuTimeMs"
2448 );
2449 assert!(
2450 !android.contains("sample.peakMemoryKb"),
2451 "Android template should tolerate BenchSample without peakMemoryKb"
2452 );
2453 assert!(
2454 !android.contains("report.phases"),
2455 "Android template should tolerate BenchReport without phases"
2456 );
2457 assert!(android.contains("ProcessMemorySampler"));
2458 assert!(android.contains("sampleIntervalMs: Long = 1000L"));
2459 assert!(android.contains("/proc/self/smaps_rollup"));
2460 assert!(android.contains("class BenchmarkWorkerService : Service()"));
2461 assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
2462 assert!(android.contains("startForegroundService(intent)"));
2463 assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
2464 assert!(android.contains("fun isBenchmarkComplete()"));
2465 assert!(!android.contains("resultLatch.await"));
2466 assert!(android.contains("memory_process\", \"isolated_worker\""));
2467
2468 let android_test = include_str!(
2469 "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
2470 );
2471 assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
2472 assert!(android_test.contains("Thread.sleep(pollMs)"));
2473 assert!(
2474 android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_HEARTBEAT_INTERVAL_SECS}})")
2475 );
2476 assert!(
2477 android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_BENCHMARK_TIMEOUT_SECS}})")
2478 );
2479 assert!(android_test.contains("activity.isBenchmarkComplete()"));
2480
2481 let ios_test = include_str!(
2482 "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
2483 );
2484 assert!(
2485 ios_test.contains("\\\"error\\\""),
2486 "iOS XCUITest template should fail when the benchmark report is an error payload"
2487 );
2488
2489 let android_manifest =
2490 include_str!("../templates/android/app/src/main/AndroidManifest.xml");
2491 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
2492 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
2493 assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
2494 assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
2495 assert!(android_manifest.contains("android:process=\":mobench_worker\""));
2496
2497 let android_build_gradle = include_str!("../templates/android/app/build.gradle");
2498 assert!(android_build_gradle.contains("generatedMainBenchSpec"));
2499 assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
2500
2501 let ios =
2502 include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
2503 assert!(
2504 !ios.contains("sample.processPeakMemoryKb"),
2505 "iOS template should not require generated bindings to expose processPeakMemoryKb"
2506 );
2507 assert!(
2508 !ios.contains(r"\.processPeakMemoryKb"),
2509 "iOS template should not require generated bindings to expose processPeakMemoryKb"
2510 );
2511 assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
2512 assert!(ios.contains("return [\n \"name\": name,"));
2513 assert!(
2514 !ios.contains("sample.cpuTimeMs"),
2515 "iOS template should tolerate BenchSample without cpuTimeMs"
2516 );
2517 assert!(
2518 !ios.contains("sample.peakMemoryKb"),
2519 "iOS template should tolerate BenchSample without peakMemoryKb"
2520 );
2521 assert!(
2522 !ios.contains("report.phases"),
2523 "iOS template should tolerate BenchReport without phases"
2524 );
2525 assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
2526 assert!(ios.contains("ProcessMemorySampler"));
2527 assert!(ios.contains("currentProcessResidentMemoryKb"));
2528 assert!(ios.contains("task_info("));
2529 assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
2530 assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
2531 assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
2532 }
2533
2534 #[test]
2535 fn test_android_worker_result_codes_do_not_collide_with_activity_constants() {
2536 let generated_runner =
2537 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
2538 let native_runner = include_str!("native_templates/android/MainActivity.kt.template");
2539
2540 for (runner, success_receiver) in [
2541 (generated_runner, "MobenchResultCode.SUCCESS ->"),
2542 (native_runner, "resultCode == MobenchResultCode.SUCCESS"),
2543 ] {
2544 assert!(
2545 runner.contains("private object MobenchResultCode"),
2546 "worker result codes must live behind a qualified namespace"
2547 );
2548 assert!(runner.contains(success_receiver));
2549 assert!(runner.contains(
2550 "if (result.errorMessage == null) MobenchResultCode.SUCCESS else MobenchResultCode.ERROR"
2551 ));
2552 assert!(
2553 !runner.contains("private const val RESULT_OK"),
2554 "Activity.RESULT_OK shadows an unqualified top-level RESULT_OK"
2555 );
2556 }
2557 assert!(generated_runner.contains("MobenchResultCode.HEARTBEAT ->"));
2558 assert!(generated_runner.contains("receiver?.send(MobenchResultCode.HEARTBEAT"));
2559 for runner in [generated_runner, native_runner] {
2560 assert!(runner.contains("watchdogHandler.postDelayed(watchdog"));
2561 assert!(runner.contains("override fun onDestroy()"));
2562 assert!(runner.contains("stopWatchdog()"));
2563 assert!(runner.contains("resultText?.text = message"));
2564 assert!(runner.contains("elapsedMs >= params.timeoutSecs * 1_000L"));
2565 }
2566 assert!(
2567 !generated_runner.contains("benchmarkComplete || workerPid == null"),
2568 "the watchdog must detect a worker killed before its first heartbeat"
2569 );
2570 assert!(native_runner.contains("MobenchResultCode.HEARTBEAT"));
2571 assert!(native_runner.contains("fun checkWorkerExit()"));
2572 assert!(
2573 native_runner.contains("Benchmark worker process exited before BENCH_JSON was emitted")
2574 );
2575 assert!(native_runner.contains("catch (e: Throwable)"));
2576 assert!(native_runner.contains("RESULT_FAILURE_JSON_EXTRA"));
2577 assert!(native_runner.contains("BENCH_FAILURE_JSON $payload"));
2578 assert!(native_runner.contains("result.failureJson?.let"));
2579 }
2580
2581 #[test]
2582 fn test_android_runners_emit_collectable_chunked_benchmark_reports() {
2583 let generated_runner =
2584 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
2585 let native_runner = include_str!("native_templates/android/MainActivity.kt.template");
2586 let boltffi_runner = include_str!("boltffi_templates/android/MainActivity.kt.template");
2587
2588 for runner in [generated_runner, native_runner, boltffi_runner] {
2589 assert!(runner.contains("BENCH_JSON_START"));
2590 assert!(runner.contains("BENCH_JSON_CHUNK"));
2591 assert!(runner.contains("BENCH_JSON_END"));
2592 assert!(runner.contains("val chunkSize = 1000"));
2593 assert!(runner.contains("Character.isHighSurrogate"));
2594 }
2595 }
2596
2597 #[test]
2598 fn generated_android_test_uses_configured_timeout_and_heartbeat() {
2599 let temp_dir = env::temp_dir().join("mobench-sdk-android-timeout-test");
2600 let _ = fs::remove_dir_all(&temp_dir);
2601 fs::create_dir_all(&temp_dir).expect("create temp dir");
2602
2603 unsafe {
2604 std::env::set_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS", "7200");
2605 std::env::set_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS", "15");
2606 }
2607 let result = generate_android_project_with_backend(
2608 &temp_dir,
2609 "my-bench-project",
2610 "sample_fns::fibonacci",
2611 crate::FfiBackend::NativeCAbi,
2612 );
2613 unsafe {
2614 std::env::remove_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS");
2615 std::env::remove_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS");
2616 }
2617 result.expect("generate Android project");
2618
2619 let android_test =
2620 fs::read_to_string(temp_dir.join(
2621 "android/app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt",
2622 ))
2623 .expect("read MainActivityTest.kt");
2624
2625 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(7200)"));
2626 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(15)"));
2627 let _ = fs::remove_dir_all(&temp_dir);
2628 }
2629
2630 #[test]
2631 fn test_validate_no_unreplaced_placeholders() {
2632 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
2634
2635 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
2637
2638 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
2640 assert!(result.is_err());
2641 let err = result.unwrap_err().to_string();
2642 assert!(err.contains("{{NAME}}"));
2643 }
2644
2645 #[test]
2646 fn test_to_pascal_case() {
2647 assert_eq!(to_pascal_case("my-project"), "MyProject");
2648 assert_eq!(to_pascal_case("my_project"), "MyProject");
2649 assert_eq!(to_pascal_case("myproject"), "Myproject");
2650 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
2651 }
2652
2653 #[test]
2654 fn test_detect_default_function_finds_benchmark() {
2655 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
2656 let _ = fs::remove_dir_all(&temp_dir);
2657 fs::create_dir_all(temp_dir.join("src")).unwrap();
2658
2659 let lib_content = r#"
2661use mobench_sdk::benchmark;
2662
2663/// Some docs
2664#[benchmark]
2665fn my_benchmark_func() {
2666 // benchmark code
2667}
2668
2669fn helper_func() {}
2670"#;
2671 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
2672 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2673
2674 let result = detect_default_function(&temp_dir, "my_crate");
2675 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
2676
2677 fs::remove_dir_all(&temp_dir).ok();
2679 }
2680
2681 #[test]
2682 fn test_detect_default_function_no_benchmark() {
2683 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
2684 let _ = fs::remove_dir_all(&temp_dir);
2685 fs::create_dir_all(temp_dir.join("src")).unwrap();
2686
2687 let lib_content = r#"
2689fn regular_function() {
2690 // no benchmark here
2691}
2692"#;
2693 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
2694
2695 let result = detect_default_function(&temp_dir, "my_crate");
2696 assert!(result.is_none());
2697
2698 fs::remove_dir_all(&temp_dir).ok();
2700 }
2701
2702 #[test]
2703 fn test_detect_default_function_pub_fn() {
2704 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
2705 let _ = fs::remove_dir_all(&temp_dir);
2706 fs::create_dir_all(temp_dir.join("src")).unwrap();
2707
2708 let lib_content = r#"
2710#[benchmark]
2711pub fn public_bench() {
2712 // benchmark code
2713}
2714"#;
2715 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
2716
2717 let result = detect_default_function(&temp_dir, "test-crate");
2718 assert_eq!(result, Some("test_crate::public_bench".to_string()));
2719
2720 fs::remove_dir_all(&temp_dir).ok();
2722 }
2723
2724 #[test]
2725 fn test_resolve_default_function_fallback() {
2726 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
2727 let _ = fs::remove_dir_all(&temp_dir);
2728 fs::create_dir_all(&temp_dir).unwrap();
2729
2730 let result = resolve_default_function(&temp_dir, "my-crate", None);
2732 assert_eq!(result, "my_crate::example_benchmark");
2733
2734 fs::remove_dir_all(&temp_dir).ok();
2736 }
2737
2738 #[test]
2739 fn test_sanitize_bundle_id_component() {
2740 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
2742 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
2744 assert_eq!(
2746 sanitize_bundle_id_component("my-project_name"),
2747 "myprojectname"
2748 );
2749 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
2751 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
2753 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
2755 assert_eq!(
2757 sanitize_bundle_id_component("My-Complex_Project-123"),
2758 "mycomplexproject123"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_generate_ios_project_bundle_id_not_duplicated() {
2764 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
2765 let _ = fs::remove_dir_all(&temp_dir);
2767 fs::create_dir_all(&temp_dir).unwrap();
2768
2769 let crate_name = "bench-mobile";
2771 let bundle_prefix = "dev.world.benchmobile";
2772 let project_pascal = "BenchRunner";
2773
2774 let result = generate_ios_project(
2775 &temp_dir,
2776 crate_name,
2777 project_pascal,
2778 bundle_prefix,
2779 "bench_mobile::test_func",
2780 );
2781 assert!(
2782 result.is_ok(),
2783 "generate_ios_project failed: {:?}",
2784 result.err()
2785 );
2786
2787 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
2789 assert!(project_yml_path.exists(), "project.yml should exist");
2790
2791 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
2793
2794 assert!(
2797 project_yml.contains("dev.world.benchmobile.BenchRunner"),
2798 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
2799 project_yml
2800 );
2801 assert!(
2802 !project_yml.contains("dev.world.benchmobile.benchmobile"),
2803 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
2804 project_yml
2805 );
2806 assert!(
2807 project_yml.contains("embed: false"),
2808 "Static xcframework dependency should be link-only, got:\n{}",
2809 project_yml
2810 );
2811
2812 fs::remove_dir_all(&temp_dir).ok();
2814 }
2815
2816 #[test]
2817 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
2818 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
2819 let _ = fs::remove_dir_all(&temp_dir);
2820 fs::create_dir_all(&temp_dir).unwrap();
2821
2822 generate_ios_project(
2823 &temp_dir,
2824 "bench_mobile",
2825 "BenchRunner",
2826 "dev.world.benchmobile",
2827 "bench_mobile::bench_prepare",
2828 )
2829 .unwrap();
2830
2831 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
2832 fs::create_dir_all(resources_dir.join("nested")).unwrap();
2833 fs::write(
2834 resources_dir.join("bench_spec.json"),
2835 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
2836 )
2837 .unwrap();
2838 fs::write(
2839 resources_dir.join("bench_meta.json"),
2840 r#"{"build_id":"build-123"}"#,
2841 )
2842 .unwrap();
2843 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
2844
2845 generate_ios_project(
2846 &temp_dir,
2847 "bench_mobile",
2848 "BenchRunner",
2849 "dev.world.benchmobile",
2850 "bench_mobile::bench_prepare",
2851 )
2852 .unwrap();
2853
2854 assert_eq!(
2855 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
2856 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
2857 );
2858 assert_eq!(
2859 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
2860 r#"{"build_id":"build-123"}"#
2861 );
2862 assert_eq!(
2863 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
2864 "keep me"
2865 );
2866
2867 fs::remove_dir_all(&temp_dir).ok();
2868 }
2869
2870 #[test]
2871 fn test_ensure_ios_project_copies_persisted_mobile_spec_into_resources() {
2872 let temp_dir = env::temp_dir().join("mobench-sdk-ios-persisted-spec-test");
2873 let _ = fs::remove_dir_all(&temp_dir);
2874 let source = temp_dir.join("target/mobile-spec/ios/bench_spec.json");
2875 fs::create_dir_all(source.parent().unwrap()).unwrap();
2876 let expected = r#"{"function":"bench_mobile::bench_prove","iterations":1,"warmup":0}"#;
2877 fs::write(&source, expected).unwrap();
2878
2879 ensure_ios_project_with_backend_options(
2880 &temp_dir,
2881 "bench-mobile",
2882 None,
2883 None,
2884 crate::FfiBackend::NativeCAbi,
2885 IosProjectOptions::default(),
2886 )
2887 .unwrap();
2888
2889 assert_eq!(
2890 fs::read_to_string(
2891 temp_dir.join("ios/BenchRunner/BenchRunner/Resources/bench_spec.json")
2892 )
2893 .unwrap(),
2894 expected
2895 );
2896
2897 fs::remove_dir_all(&temp_dir).ok();
2898 }
2899
2900 #[test]
2901 fn test_generate_ios_native_backend_runner_template() {
2902 let temp_dir = env::temp_dir().join("mobench-sdk-ios-native-test");
2903 let _ = fs::remove_dir_all(&temp_dir);
2904 fs::create_dir_all(&temp_dir).unwrap();
2905
2906 generate_ios_project_with_backend_options(
2907 &temp_dir,
2908 "native_benchmark",
2909 "BenchRunner",
2910 "dev.world.nativebenchmark",
2911 "native_benchmark::bench_prove",
2912 crate::FfiBackend::NativeCAbi,
2913 IosProjectOptions {
2914 runner: IosRunner::UikitLegacy,
2915 ..IosProjectOptions::default()
2916 },
2917 )
2918 .unwrap();
2919
2920 let ffi =
2921 fs::read_to_string(temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift"))
2922 .unwrap();
2923 assert!(ffi.contains("mobench_run_benchmark_json"));
2924 assert!(ffi.contains("mobench_free_buf"));
2925 assert!(ffi.contains("BENCH_FUNCTION"));
2926 assert!(
2927 !ffi.contains("runBenchmark(spec:"),
2928 "native iOS runner must call the JSON C ABI, not UniFFI runBenchmark"
2929 );
2930 assert!(
2931 !ffi.contains("let report: BenchReport"),
2932 "native iOS runner should operate on JSON, not UniFFI BenchReport"
2933 );
2934
2935 let header = fs::read_to_string(
2936 temp_dir.join("ios/BenchRunner/BenchRunner/Generated/native_benchmarkFFI.h"),
2937 )
2938 .unwrap();
2939 assert!(header.contains("mobench_run_benchmark_json"));
2940 let bridging_header = fs::read_to_string(
2941 temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h"),
2942 )
2943 .unwrap();
2944 assert!(bridging_header.contains("#import \"native_benchmarkFFI.h\""));
2945 let package_manifest =
2946 fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")).unwrap();
2947 assert!(package_manifest.contains("../native_benchmark.xcframework"));
2948 assert!(package_manifest.contains("SWIFT_OBJC_BRIDGING_HEADER"));
2949 assert!(!package_manifest.contains("uniffi"));
2950
2951 let runner = fs::read_to_string(
2952 temp_dir.join("ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift"),
2953 )
2954 .unwrap();
2955 assert!(runner.contains("jsonLabel.accessibilityValue = result.jsonReport"));
2956 assert!(runner.contains("completionLabel.accessibilityLabel = \"completed\""));
2957 assert!(
2958 runner.contains("heartbeatButton.accessibilityIdentifier = \"benchmarkHeartbeat\"")
2959 );
2960 assert!(runner.contains("@objc private func heartbeatTapped()"));
2961
2962 fs::remove_dir_all(&temp_dir).ok();
2963 }
2964
2965 #[test]
2966 fn test_generate_ios_boltffi_backend_runner_template() {
2967 let temp_dir = env::temp_dir().join("mobench-sdk-ios-boltffi-test");
2968 let _ = fs::remove_dir_all(&temp_dir);
2969 fs::create_dir_all(&temp_dir).unwrap();
2970
2971 generate_ios_project_with_backend(
2972 &temp_dir,
2973 "bolt_benchmark",
2974 "BenchRunner",
2975 "dev.world.boltbenchmark",
2976 "bolt_benchmark::bench_prove",
2977 crate::FfiBackend::BoltFfi,
2978 )
2979 .unwrap();
2980
2981 let ffi =
2982 fs::read_to_string(temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift"))
2983 .unwrap();
2984 assert!(ffi.contains("runBenchmarkJson(specJson:"));
2985 assert!(ffi.contains("BENCH_FUNCTION"));
2986 assert!(
2987 !ffi.contains("runBenchmark(spec:"),
2988 "BoltFFI iOS runner must call BoltFFI JSON bindings, not UniFFI runBenchmark"
2989 );
2990 assert!(
2991 !ffi.contains("mobench_run_benchmark_json"),
2992 "BoltFFI iOS runner must not call the native C ABI bridge"
2993 );
2994 let bridging_header = fs::read_to_string(
2995 temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h"),
2996 )
2997 .unwrap();
2998 assert!(
2999 !bridging_header.contains("#import"),
3000 "BoltFFI iOS runner should import the generated C module from Swift, not a UniFFI-style bridging header"
3001 );
3002
3003 let boltffi_toml = fs::read_to_string(temp_dir.join("boltffi.toml")).unwrap();
3004 assert!(boltffi_toml.contains("module_name = \"BenchRunner\""));
3005 assert!(boltffi_toml.contains("ffi_module_name = \"bolt_benchmarkFFI\""));
3006 assert!(boltffi_toml.contains(
3007 "output = \"target/mobench/ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated\""
3008 ));
3009
3010 fs::remove_dir_all(&temp_dir).ok();
3011 }
3012
3013 #[test]
3014 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
3015 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
3016 let _ = fs::remove_dir_all(&temp_dir);
3017 fs::create_dir_all(&temp_dir).unwrap();
3018
3019 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
3020 .expect("initial iOS project generation should succeed");
3021
3022 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
3023 assert!(content_view_path.exists(), "ContentView.swift should exist");
3024
3025 fs::write(&content_view_path, "stale generated content").unwrap();
3026
3027 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
3028 .expect("refreshing existing iOS project should succeed");
3029
3030 let refreshed = fs::read_to_string(&content_view_path).unwrap();
3031 assert!(
3032 refreshed.contains("ProfileLaunchOptions"),
3033 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
3034 refreshed
3035 );
3036 assert!(
3037 refreshed.contains("repeatUntilMs"),
3038 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
3039 refreshed
3040 );
3041 assert!(
3042 refreshed.contains("Task.detached(priority: .userInitiated)"),
3043 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
3044 refreshed
3045 );
3046 assert!(
3047 refreshed.contains("await MainActor.run"),
3048 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
3049 refreshed
3050 );
3051 assert!(
3052 refreshed.contains(".accessibilityLabel(reportJSON)"),
3053 "refreshed ContentView.swift should expose report JSON to XCUITest, got:\n{}",
3054 refreshed
3055 );
3056 assert!(
3057 refreshed.contains(".accessibilityValue(reportJSON)"),
3058 "refreshed ContentView.swift should expose report JSON as an accessibility value, got:\n{}",
3059 refreshed
3060 );
3061 assert!(
3062 refreshed.contains(".frame(width: 1, height: 1)"),
3063 "refreshed ContentView.swift should keep the report element in the accessibility tree, got:\n{}",
3064 refreshed
3065 );
3066 assert!(
3067 refreshed.contains(".opacity(0.01)"),
3068 "refreshed ContentView.swift should keep the report element nearly transparent, got:\n{}",
3069 refreshed
3070 );
3071 assert!(
3072 refreshed.contains(".accessibilityIdentifier(\"benchmarkHeartbeat\")"),
3073 "refreshed ContentView.swift should expose a remote-session heartbeat control, got:\n{}",
3074 refreshed
3075 );
3076
3077 fs::remove_dir_all(&temp_dir).ok();
3078 }
3079
3080 #[test]
3081 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
3082 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
3083 let _ = fs::remove_dir_all(&temp_dir);
3084 fs::create_dir_all(&temp_dir).unwrap();
3085
3086 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
3087 .expect("initial iOS project generation should succeed");
3088
3089 let ui_test_path =
3090 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
3091 assert!(
3092 ui_test_path.exists(),
3093 "BenchRunnerUITests.swift should exist"
3094 );
3095
3096 fs::write(&ui_test_path, "stale generated content").unwrap();
3097
3098 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
3099 .expect("refreshing existing iOS project should succeed");
3100
3101 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
3102 assert!(
3103 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
3104 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
3105 refreshed
3106 );
3107 assert!(
3108 refreshed.contains(
3109 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
3110 ),
3111 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
3112 refreshed
3113 );
3114 assert!(
3115 refreshed.contains("waitForBenchmarkCompletion(completedIndicator, app: app)"),
3116 "refreshed BenchRunnerUITests.swift should use heartbeat polling, got:\n{}",
3117 refreshed
3118 );
3119 assert!(
3120 refreshed.contains("NSPredicate(format: \"label == %@\", \"completed\")"),
3121 "refreshed BenchRunnerUITests.swift should wait for the completed state, got:\n{}",
3122 refreshed
3123 );
3124 assert!(
3125 refreshed
3126 .contains("XCTWaiter.wait(for: [completedExpectation], timeout: waitInterval)"),
3127 "refreshed BenchRunnerUITests.swift should predicate-wait without busy polling, got:\n{}",
3128 refreshed
3129 );
3130 assert!(
3131 refreshed.contains("MOBENCH_HEARTBEAT waiting for benchmark completion"),
3132 "refreshed BenchRunnerUITests.swift should emit heartbeat activity, got:\n{}",
3133 refreshed
3134 );
3135 assert!(
3136 refreshed.contains("heartbeatControl.tap()"),
3137 "refreshed BenchRunnerUITests.swift should keep remote sessions active with app interaction, got:\n{}",
3138 refreshed
3139 );
3140 assert!(
3141 refreshed.contains("reportElement.value as? String"),
3142 "refreshed BenchRunnerUITests.swift should read report JSON from the accessibility value, got:\n{}",
3143 refreshed
3144 );
3145 assert!(
3146 refreshed.contains("firstValidJSON([reportValue, reportElement.label])"),
3147 "refreshed BenchRunnerUITests.swift should fall back across valid accessibility JSON channels, got:\n{}",
3148 refreshed
3149 );
3150 assert!(
3151 refreshed.contains("JSONSerialization.jsonObject(with: data)"),
3152 "refreshed BenchRunnerUITests.swift should reject corrupt accessibility values, got:\n{}",
3153 refreshed
3154 );
3155 assert!(
3156 !refreshed.contains("completedIndicator.waitForExistence"),
3157 "refreshed BenchRunnerUITests.swift should not treat marker existence as completion, got:\n{}",
3158 refreshed
3159 );
3160
3161 fs::remove_dir_all(&temp_dir).ok();
3162 }
3163
3164 #[test]
3165 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
3166 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
3167 let _ = fs::remove_dir_all(&temp_dir);
3168 fs::create_dir_all(&temp_dir).unwrap();
3169
3170 let result = generate_ios_project_with_timeout(
3171 &temp_dir,
3172 "sample_fns",
3173 "BenchRunner",
3174 "dev.world.samplefns",
3175 "sample_fns::example_benchmark",
3176 1200,
3177 crate::FfiBackend::Uniffi,
3178 );
3179
3180 assert!(result.is_ok(), "generate_ios_project should succeed");
3181
3182 let ui_test_path =
3183 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
3184 let contents = fs::read_to_string(&ui_test_path).unwrap();
3185 assert!(
3186 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
3187 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
3188 contents
3189 );
3190
3191 fs::remove_dir_all(&temp_dir).ok();
3192 }
3193
3194 #[test]
3195 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
3196 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
3197 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
3198 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
3199 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
3200 }
3201
3202 #[test]
3203 fn test_cross_platform_naming_consistency() {
3204 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
3206 let _ = fs::remove_dir_all(&temp_dir);
3207 fs::create_dir_all(&temp_dir).unwrap();
3208
3209 let project_name = "bench-mobile";
3210
3211 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
3213 assert!(
3214 result.is_ok(),
3215 "generate_android_project failed: {:?}",
3216 result.err()
3217 );
3218
3219 let bundle_id_component = sanitize_bundle_id_component(project_name);
3221 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
3222 let result = generate_ios_project(
3223 &temp_dir,
3224 &project_name.replace('-', "_"),
3225 "BenchRunner",
3226 &bundle_prefix,
3227 "bench_mobile::test_func",
3228 );
3229 assert!(
3230 result.is_ok(),
3231 "generate_ios_project failed: {:?}",
3232 result.err()
3233 );
3234
3235 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
3237 .expect("Failed to read Android build.gradle");
3238
3239 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
3241 .expect("Failed to read iOS project.yml");
3242
3243 assert!(
3247 android_build_gradle.contains("dev.world.benchmobile"),
3248 "Android package should be 'dev.world.benchmobile', got:\n{}",
3249 android_build_gradle
3250 );
3251 assert!(
3252 ios_project_yml.contains("dev.world.benchmobile"),
3253 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
3254 ios_project_yml
3255 );
3256
3257 assert!(
3259 !android_build_gradle.contains("dev.world.bench-mobile"),
3260 "Android package should NOT contain hyphens"
3261 );
3262 assert!(
3263 !android_build_gradle.contains("dev.world.bench_mobile"),
3264 "Android package should NOT contain underscores"
3265 );
3266
3267 fs::remove_dir_all(&temp_dir).ok();
3269 }
3270
3271 #[test]
3272 fn test_cross_platform_version_consistency() {
3273 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
3275 let _ = fs::remove_dir_all(&temp_dir);
3276 fs::create_dir_all(&temp_dir).unwrap();
3277
3278 let project_name = "test-project";
3279
3280 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
3282 assert!(
3283 result.is_ok(),
3284 "generate_android_project failed: {:?}",
3285 result.err()
3286 );
3287
3288 let bundle_id_component = sanitize_bundle_id_component(project_name);
3290 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
3291 let result = generate_ios_project(
3292 &temp_dir,
3293 &project_name.replace('-', "_"),
3294 "BenchRunner",
3295 &bundle_prefix,
3296 "test_project::test_func",
3297 );
3298 assert!(
3299 result.is_ok(),
3300 "generate_ios_project failed: {:?}",
3301 result.err()
3302 );
3303
3304 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
3306 .expect("Failed to read Android build.gradle");
3307
3308 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
3310 .expect("Failed to read iOS project.yml");
3311
3312 assert!(
3314 android_build_gradle.contains("versionName \"1.0.0\""),
3315 "Android versionName should be '1.0.0', got:\n{}",
3316 android_build_gradle
3317 );
3318 assert!(
3319 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
3320 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
3321 ios_project_yml
3322 );
3323
3324 fs::remove_dir_all(&temp_dir).ok();
3326 }
3327
3328 #[test]
3329 fn test_bundle_id_prefix_consistency() {
3330 let test_cases = vec![
3332 ("my-project", "dev.world.myproject"),
3333 ("bench_mobile", "dev.world.benchmobile"),
3334 ("TestApp", "dev.world.testapp"),
3335 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
3336 (
3337 "app_with_many_underscores",
3338 "dev.world.appwithmanyunderscores",
3339 ),
3340 ];
3341
3342 for (input, expected_prefix) in test_cases {
3343 let sanitized = sanitize_bundle_id_component(input);
3344 let full_prefix = format!("dev.world.{}", sanitized);
3345 assert_eq!(
3346 full_prefix, expected_prefix,
3347 "For input '{}', expected '{}' but got '{}'",
3348 input, expected_prefix, full_prefix
3349 );
3350 }
3351 }
3352}