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 DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
20const DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS: u64 = 1800;
21const DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS: u64 = 10;
22
23#[derive(Debug, Clone)]
25pub struct TemplateVar {
26 pub name: &'static str,
27 pub value: String,
28}
29
30pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
47 let output_dir = &config.output_dir;
48 let project_slug = sanitize_package_name(&config.project_name);
49 let project_pascal = to_pascal_case(&project_slug);
50 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
52 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
53
54 fs::create_dir_all(output_dir)?;
56
57 generate_bench_mobile_crate(output_dir, &project_slug)?;
59
60 let default_function = "example_fibonacci";
63
64 match config.target {
66 Target::Android => {
67 generate_android_project(output_dir, &project_slug, default_function)?;
68 }
69 Target::Ios => {
70 generate_ios_project(
71 output_dir,
72 &project_slug,
73 &project_pascal,
74 &bundle_prefix,
75 default_function,
76 )?;
77 }
78 Target::Both => {
79 generate_android_project(output_dir, &project_slug, default_function)?;
80 generate_ios_project(
81 output_dir,
82 &project_slug,
83 &project_pascal,
84 &bundle_prefix,
85 default_function,
86 )?;
87 }
88 }
89
90 generate_config_file(output_dir, config)?;
92
93 if config.generate_examples {
95 generate_example_benchmarks(output_dir)?;
96 }
97
98 Ok(output_dir.clone())
99}
100
101fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
103 let crate_dir = output_dir.join("bench-mobile");
104 fs::create_dir_all(crate_dir.join("src"))?;
105
106 let crate_name = format!("{}-bench-mobile", project_name);
107
108 let cargo_toml = format!(
112 r#"[package]
113name = "{}"
114version = "0.1.0"
115edition = "2021"
116
117[lib]
118crate-type = ["cdylib", "staticlib", "rlib"]
119
120[dependencies]
121mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
122uniffi = "0.28"
123{} = {{ path = ".." }}
124
125[features]
126default = []
127
128[build-dependencies]
129uniffi = {{ version = "0.28", features = ["build"] }}
130
131# Binary for generating UniFFI bindings (used by mobench build)
132[[bin]]
133name = "uniffi-bindgen"
134path = "src/bin/uniffi-bindgen.rs"
135
136# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
137# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
138# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
139#
140# Add this to your root Cargo.toml:
141# [workspace.dependencies]
142# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
143#
144# Then in each crate that uses rustls:
145# [dependencies]
146# rustls = {{ workspace = true }}
147"#,
148 crate_name, project_name
149 );
150
151 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
152
153 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
155//!
156//! This crate provides the FFI boundary between Rust benchmarks and mobile
157//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
158
159use uniffi;
160
161// Ensure the user crate is linked so benchmark registrations are pulled in.
162extern crate {{USER_CRATE}} as _bench_user_crate;
163
164// Re-export mobench-sdk types with UniFFI annotations
165#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
166pub struct BenchSpec {
167 pub name: String,
168 pub iterations: u32,
169 pub warmup: u32,
170}
171
172#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
173pub struct BenchSample {
174 pub duration_ns: u64,
175 pub cpu_time_ms: Option<u64>,
176 pub peak_memory_kb: Option<u64>,
177 pub process_peak_memory_kb: Option<u64>,
178}
179
180#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
181pub struct SemanticPhase {
182 pub name: String,
183 pub duration_ns: u64,
184}
185
186#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
187pub struct HarnessTimelineSpan {
188 pub phase: String,
189 pub start_offset_ns: u64,
190 pub end_offset_ns: u64,
191 pub iteration: Option<u32>,
192}
193
194#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
195pub struct BenchReport {
196 pub spec: BenchSpec,
197 pub samples: Vec<BenchSample>,
198 pub phases: Vec<SemanticPhase>,
199 pub timeline: Vec<HarnessTimelineSpan>,
200}
201
202#[derive(Debug, thiserror::Error, uniffi::Error)]
203#[uniffi(flat_error)]
204pub enum BenchError {
205 #[error("iterations must be greater than zero")]
206 InvalidIterations,
207
208 #[error("unknown benchmark function: {name}")]
209 UnknownFunction { name: String },
210
211 #[error("benchmark execution failed: {reason}")]
212 ExecutionFailed { reason: String },
213}
214
215// Convert from mobench-sdk types
216impl From<mobench_sdk::BenchSpec> for BenchSpec {
217 fn from(spec: mobench_sdk::BenchSpec) -> Self {
218 Self {
219 name: spec.name,
220 iterations: spec.iterations,
221 warmup: spec.warmup,
222 }
223 }
224}
225
226impl From<BenchSpec> for mobench_sdk::BenchSpec {
227 fn from(spec: BenchSpec) -> Self {
228 Self {
229 name: spec.name,
230 iterations: spec.iterations,
231 warmup: spec.warmup,
232 }
233 }
234}
235
236impl From<mobench_sdk::BenchSample> for BenchSample {
237 fn from(sample: mobench_sdk::BenchSample) -> Self {
238 Self {
239 duration_ns: sample.duration_ns,
240 cpu_time_ms: sample.cpu_time_ms,
241 peak_memory_kb: sample.peak_memory_kb,
242 process_peak_memory_kb: sample.process_peak_memory_kb,
243 }
244 }
245}
246
247impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
248 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
249 Self {
250 name: phase.name,
251 duration_ns: phase.duration_ns,
252 }
253 }
254}
255
256impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
257 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
258 Self {
259 phase: span.phase,
260 start_offset_ns: span.start_offset_ns,
261 end_offset_ns: span.end_offset_ns,
262 iteration: span.iteration,
263 }
264 }
265}
266
267impl From<mobench_sdk::RunnerReport> for BenchReport {
268 fn from(report: mobench_sdk::RunnerReport) -> Self {
269 Self {
270 spec: report.spec.into(),
271 samples: report.samples.into_iter().map(Into::into).collect(),
272 phases: report.phases.into_iter().map(Into::into).collect(),
273 timeline: report.timeline.into_iter().map(Into::into).collect(),
274 }
275 }
276}
277
278impl From<mobench_sdk::BenchError> for BenchError {
279 fn from(err: mobench_sdk::BenchError) -> Self {
280 match err {
281 mobench_sdk::BenchError::Runner(runner_err) => {
282 BenchError::ExecutionFailed {
283 reason: runner_err.to_string(),
284 }
285 }
286 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
287 BenchError::UnknownFunction { name }
288 }
289 _ => BenchError::ExecutionFailed {
290 reason: err.to_string(),
291 },
292 }
293 }
294}
295
296/// Runs a benchmark by name with the given specification
297///
298/// This is the main FFI entry point called from mobile platforms.
299#[uniffi::export]
300pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
301 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
302 let report = mobench_sdk::run_benchmark(sdk_spec)?;
303 Ok(report.into())
304}
305
306// Generate UniFFI scaffolding
307uniffi::setup_scaffolding!();
308"#;
309
310 let lib_rs = render_template(
311 lib_rs_template,
312 &[TemplateVar {
313 name: "USER_CRATE",
314 value: project_name.replace('-', "_"),
315 }],
316 );
317 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
318
319 let build_rs = r#"fn main() {
321 uniffi::generate_scaffolding("src/lib.rs").unwrap();
322}
323"#;
324
325 fs::write(crate_dir.join("build.rs"), build_rs)?;
326
327 let bin_dir = crate_dir.join("src/bin");
329 fs::create_dir_all(&bin_dir)?;
330 let uniffi_bindgen_rs = r#"fn main() {
331 uniffi::uniffi_bindgen_main()
332}
333"#;
334 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
335
336 Ok(())
337}
338
339pub fn generate_android_project(
350 output_dir: &Path,
351 project_slug: &str,
352 default_function: &str,
353) -> Result<(), BenchError> {
354 generate_android_project_with_backend(
355 output_dir,
356 project_slug,
357 default_function,
358 crate::FfiBackend::Uniffi,
359 )
360}
361
362pub fn generate_android_project_with_backend(
364 output_dir: &Path,
365 project_slug: &str,
366 default_function: &str,
367 ffi_backend: crate::FfiBackend,
368) -> Result<(), BenchError> {
369 let android_benchmark_timeout_secs = resolve_positive_u64_env(
370 "MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS",
371 DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS,
372 );
373 let android_heartbeat_interval_secs = resolve_positive_u64_env(
374 "MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS",
375 DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
376 );
377 generate_android_project_with_options(
378 output_dir,
379 project_slug,
380 default_function,
381 ffi_backend,
382 android_benchmark_timeout_secs,
383 android_heartbeat_interval_secs,
384 )
385}
386
387fn generate_android_project_with_options(
388 output_dir: &Path,
389 project_slug: &str,
390 default_function: &str,
391 ffi_backend: crate::FfiBackend,
392 android_benchmark_timeout_secs: u64,
393 android_heartbeat_interval_secs: u64,
394) -> Result<(), BenchError> {
395 let target_dir = output_dir.join("android");
396 reset_generated_project_dir(&target_dir)?;
397 let library_name = project_slug.replace('-', "_");
398 let project_pascal = to_pascal_case(project_slug);
399 let package_id_component = sanitize_bundle_id_component(project_slug);
402 let package_name = format!("dev.world.{}", package_id_component);
403 let vars = vec![
404 TemplateVar {
405 name: "PROJECT_NAME",
406 value: project_slug.to_string(),
407 },
408 TemplateVar {
409 name: "PROJECT_NAME_PASCAL",
410 value: project_pascal.clone(),
411 },
412 TemplateVar {
413 name: "APP_NAME",
414 value: format!("{} Benchmark", project_pascal),
415 },
416 TemplateVar {
417 name: "PACKAGE_NAME",
418 value: package_name.clone(),
419 },
420 TemplateVar {
421 name: "UNIFFI_NAMESPACE",
422 value: library_name.clone(),
423 },
424 TemplateVar {
425 name: "LIBRARY_NAME",
426 value: library_name,
427 },
428 TemplateVar {
429 name: "DEFAULT_FUNCTION",
430 value: default_function.to_string(),
431 },
432 TemplateVar {
433 name: "ANDROID_BENCHMARK_TIMEOUT_SECS",
434 value: android_benchmark_timeout_secs.to_string(),
435 },
436 TemplateVar {
437 name: "ANDROID_HEARTBEAT_INTERVAL_SECS",
438 value: android_heartbeat_interval_secs.to_string(),
439 },
440 ];
441 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
442
443 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
446 if !ffi_backend.uses_uniffi() {
447 write_native_android_main_activity(&target_dir, &package_name, &vars)?;
448 }
449
450 Ok(())
451}
452
453fn write_native_android_main_activity(
454 target_dir: &Path,
455 package_name: &str,
456 vars: &[TemplateVar],
457) -> Result<(), BenchError> {
458 let relative_package = package_name.replace('.', "/");
459 let path = target_dir
460 .join("app/src/main/java")
461 .join(relative_package)
462 .join("MainActivity.kt");
463 let rendered = render_template(NATIVE_ANDROID_MAIN_ACTIVITY_TEMPLATE, vars);
464 validate_no_unreplaced_placeholders(&rendered, Path::new("MainActivity.kt"))?;
465 fs::write(&path, rendered).map_err(BenchError::Io)
466}
467
468fn collect_preserved_files(
469 root: &Path,
470 current: &Path,
471 preserved: &mut Vec<(PathBuf, Vec<u8>)>,
472) -> Result<(), BenchError> {
473 let mut entries = fs::read_dir(current)?
474 .collect::<Result<Vec<_>, _>>()
475 .map_err(BenchError::Io)?;
476 entries.sort_by_key(|entry| entry.path());
477
478 for entry in entries {
479 let path = entry.path();
480 if path.is_dir() {
481 collect_preserved_files(root, &path, preserved)?;
482 continue;
483 }
484
485 let relative = path.strip_prefix(root).map_err(|e| {
486 BenchError::Build(format!(
487 "Failed to preserve generated resource {:?}: {}",
488 path, e
489 ))
490 })?;
491 preserved.push((relative.to_path_buf(), fs::read(&path)?));
492 }
493
494 Ok(())
495}
496
497fn collect_preserved_ios_resources(
498 target_dir: &Path,
499) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
500 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
501 let mut preserved = Vec::new();
502
503 if resources_dir.exists() {
504 collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
505 }
506
507 Ok(preserved)
508}
509
510fn restore_preserved_ios_resources(
511 target_dir: &Path,
512 preserved_resources: &[(PathBuf, Vec<u8>)],
513) -> Result<(), BenchError> {
514 if preserved_resources.is_empty() {
515 return Ok(());
516 }
517
518 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
519 for (relative, contents) in preserved_resources {
520 let resource_path = resources_dir.join(relative);
521 if let Some(parent) = resource_path.parent() {
522 fs::create_dir_all(parent)?;
523 }
524 fs::write(resource_path, contents)?;
525 }
526
527 Ok(())
528}
529
530fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
531 if target_dir.exists() {
532 fs::remove_dir_all(target_dir).map_err(|e| {
533 BenchError::Build(format!(
534 "Failed to clear existing generated project at {:?}: {}",
535 target_dir, e
536 ))
537 })?;
538 }
539 Ok(())
540}
541
542fn move_kotlin_files_to_package_dir(
552 android_dir: &Path,
553 package_name: &str,
554) -> Result<(), BenchError> {
555 let package_path = package_name.replace('.', "/");
557
558 let main_java_dir = android_dir.join("app/src/main/java");
560 let main_package_dir = main_java_dir.join(&package_path);
561 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
562
563 let test_java_dir = android_dir.join("app/src/androidTest/java");
565 let test_package_dir = test_java_dir.join(&package_path);
566 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
567
568 Ok(())
569}
570
571fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
573 let src_file = src_dir.join(filename);
574 if !src_file.exists() {
575 return Ok(());
577 }
578
579 fs::create_dir_all(dest_dir).map_err(|e| {
581 BenchError::Build(format!(
582 "Failed to create package directory {:?}: {}",
583 dest_dir, e
584 ))
585 })?;
586
587 let dest_file = dest_dir.join(filename);
588
589 fs::copy(&src_file, &dest_file).map_err(|e| {
591 BenchError::Build(format!(
592 "Failed to copy {} to {:?}: {}",
593 filename, dest_file, e
594 ))
595 })?;
596
597 fs::remove_file(&src_file).map_err(|e| {
598 BenchError::Build(format!(
599 "Failed to remove original file {:?}: {}",
600 src_file, e
601 ))
602 })?;
603
604 Ok(())
605}
606
607pub fn generate_ios_project(
620 output_dir: &Path,
621 project_slug: &str,
622 project_pascal: &str,
623 bundle_prefix: &str,
624 default_function: &str,
625) -> Result<(), BenchError> {
626 generate_ios_project_with_backend(
627 output_dir,
628 project_slug,
629 project_pascal,
630 bundle_prefix,
631 default_function,
632 crate::FfiBackend::Uniffi,
633 )
634}
635
636pub fn generate_ios_project_with_backend(
638 output_dir: &Path,
639 project_slug: &str,
640 project_pascal: &str,
641 bundle_prefix: &str,
642 default_function: &str,
643 ffi_backend: crate::FfiBackend,
644) -> Result<(), BenchError> {
645 let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
646 std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
647 .ok()
648 .as_deref(),
649 );
650 generate_ios_project_with_timeout(
651 output_dir,
652 project_slug,
653 project_pascal,
654 bundle_prefix,
655 default_function,
656 ios_benchmark_timeout_secs,
657 ffi_backend,
658 )
659}
660
661fn generate_ios_project_with_timeout(
662 output_dir: &Path,
663 project_slug: &str,
664 project_pascal: &str,
665 bundle_prefix: &str,
666 default_function: &str,
667 ios_benchmark_timeout_secs: u64,
668 ffi_backend: crate::FfiBackend,
669) -> Result<(), BenchError> {
670 let target_dir = output_dir.join("ios");
671 let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
672 reset_generated_project_dir(&target_dir)?;
673 let sanitized_bundle_prefix = {
676 let parts: Vec<&str> = bundle_prefix.split('.').collect();
677 parts
678 .iter()
679 .map(|part| sanitize_bundle_id_component(part))
680 .collect::<Vec<_>>()
681 .join(".")
682 };
683 let vars = vec![
687 TemplateVar {
688 name: "DEFAULT_FUNCTION",
689 value: default_function.to_string(),
690 },
691 TemplateVar {
692 name: "PROJECT_NAME_PASCAL",
693 value: project_pascal.to_string(),
694 },
695 TemplateVar {
696 name: "BUNDLE_ID_PREFIX",
697 value: sanitized_bundle_prefix.clone(),
698 },
699 TemplateVar {
700 name: "BUNDLE_ID",
701 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
702 },
703 TemplateVar {
704 name: "LIBRARY_NAME",
705 value: project_slug.replace('-', "_"),
706 },
707 TemplateVar {
708 name: "IOS_BENCHMARK_TIMEOUT_SECS",
709 value: ios_benchmark_timeout_secs.to_string(),
710 },
711 ];
712 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
713 if !ffi_backend.uses_uniffi() {
714 write_native_ios_bench_runner_ffi(&target_dir, project_pascal, &vars)?;
715 }
716 restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
717 Ok(())
718}
719
720fn write_native_ios_bench_runner_ffi(
721 target_dir: &Path,
722 project_pascal: &str,
723 vars: &[TemplateVar],
724) -> Result<(), BenchError> {
725 let app_dir = target_dir.join("BenchRunner").join(project_pascal);
726 let path = app_dir.join("BenchRunnerFFI.swift");
727 let generated_dir = app_dir.join("Generated");
728 fs::create_dir_all(&generated_dir).map_err(BenchError::Io)?;
729 let library_name = vars
730 .iter()
731 .find(|var| var.name == "LIBRARY_NAME")
732 .map(|var| var.value.as_str())
733 .unwrap_or("bench_mobile");
734 let header_path = generated_dir.join(format!("{library_name}FFI.h"));
735 fs::write(&header_path, native_c_abi_header(library_name)).map_err(BenchError::Io)?;
736
737 let rendered = render_template(NATIVE_IOS_BENCH_RUNNER_FFI_TEMPLATE, vars);
738 validate_no_unreplaced_placeholders(&rendered, Path::new("BenchRunnerFFI.swift"))?;
739 fs::write(&path, rendered).map_err(BenchError::Io)
740}
741
742fn native_c_abi_header(framework_name: &str) -> String {
743 let guard = format!(
744 "{}_MOBENCH_NATIVE_C_ABI_H",
745 framework_name.to_ascii_uppercase()
746 )
747 .replace('-', "_");
748 format!(
749 r#"#ifndef {guard}
750#define {guard}
751
752#include <stdint.h>
753#include <stddef.h>
754
755#ifdef __cplusplus
756extern "C" {{
757#endif
758
759typedef struct MobenchBuf {{
760 uint8_t *ptr;
761 uintptr_t len;
762 uintptr_t cap;
763}} MobenchBuf;
764
765int32_t mobench_run_benchmark_json(const uint8_t *spec_ptr, uintptr_t spec_len, MobenchBuf *out);
766void mobench_free_buf(MobenchBuf *buf);
767const char *mobench_last_error_message(void);
768
769#ifdef __cplusplus
770}}
771#endif
772
773#endif
774"#
775 )
776}
777
778fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
779 value
780 .and_then(|raw| raw.parse::<u64>().ok())
781 .filter(|secs| *secs > 0)
782 .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
783}
784
785fn resolve_positive_u64_env(name: &str, default: u64) -> u64 {
786 std::env::var(name)
787 .ok()
788 .and_then(|raw| raw.parse::<u64>().ok())
789 .filter(|secs| *secs > 0)
790 .unwrap_or(default)
791}
792
793fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
795 let config_target = match config.target {
796 Target::Ios => "ios",
797 Target::Android | Target::Both => "android",
798 };
799 let config_content = format!(
800 r#"# mobench configuration
801# This file controls how benchmarks are executed on devices.
802
803target = "{}"
804function = "example_fibonacci"
805iterations = 100
806warmup = 10
807device_matrix = "device-matrix.yaml"
808device_tags = ["default"]
809
810[browserstack]
811app_automate_username = "${{BROWSERSTACK_USERNAME}}"
812app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
813project = "{}-benchmarks"
814
815[ios_xcuitest]
816app = "target/ios/BenchRunner.ipa"
817test_suite = "target/ios/BenchRunnerUITests.zip"
818"#,
819 config_target, config.project_name
820 );
821
822 fs::write(output_dir.join("bench-config.toml"), config_content)?;
823
824 Ok(())
825}
826
827fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
829 let examples_dir = output_dir.join("benches");
830 fs::create_dir_all(&examples_dir)?;
831
832 let example_content = r#"//! Example benchmarks
833//!
834//! This file demonstrates how to write benchmarks with mobench-sdk.
835
836use mobench_sdk::benchmark;
837
838/// Simple benchmark example
839#[benchmark]
840fn example_fibonacci() {
841 let result = fibonacci(30);
842 std::hint::black_box(result);
843}
844
845/// Another example with a loop
846#[benchmark]
847fn example_sum() {
848 let mut sum = 0u64;
849 for i in 0..10000 {
850 sum = sum.wrapping_add(i);
851 }
852 std::hint::black_box(sum);
853}
854
855// Helper function (not benchmarked)
856fn fibonacci(n: u32) -> u64 {
857 match n {
858 0 => 0,
859 1 => 1,
860 _ => {
861 let mut a = 0u64;
862 let mut b = 1u64;
863 for _ in 2..=n {
864 let next = a.wrapping_add(b);
865 a = b;
866 b = next;
867 }
868 b
869 }
870 }
871}
872"#;
873
874 fs::write(examples_dir.join("example.rs"), example_content)?;
875
876 Ok(())
877}
878
879const TEMPLATE_EXTENSIONS: &[&str] = &[
881 "gradle",
882 "xml",
883 "kt",
884 "java",
885 "swift",
886 "yml",
887 "yaml",
888 "json",
889 "toml",
890 "md",
891 "txt",
892 "h",
893 "m",
894 "plist",
895 "pbxproj",
896 "xcscheme",
897 "xcworkspacedata",
898 "entitlements",
899 "modulemap",
900];
901
902fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
903 for entry in dir.entries() {
904 match entry {
905 DirEntry::Dir(sub) => {
906 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
908 continue;
909 }
910 render_dir(sub, out_root, vars)?;
911 }
912 DirEntry::File(file) => {
913 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
914 continue;
915 }
916 let mut relative = file.path().to_path_buf();
918 let mut contents = file.contents().to_vec();
919
920 let is_explicit_template = relative
922 .extension()
923 .map(|ext| ext == "template")
924 .unwrap_or(false);
925
926 let should_render = is_explicit_template || is_template_file(&relative);
928
929 if is_explicit_template {
930 relative.set_extension("");
932 }
933
934 if should_render && let Ok(text) = std::str::from_utf8(&contents) {
935 let rendered = render_template(text, vars);
936 validate_no_unreplaced_placeholders(&rendered, &relative)?;
938 contents = rendered.into_bytes();
939 }
940
941 let out_path = out_root.join(relative);
942 if let Some(parent) = out_path.parent() {
943 fs::create_dir_all(parent)?;
944 }
945 fs::write(&out_path, contents)?;
946 }
947 }
948 }
949 Ok(())
950}
951
952fn is_template_file(path: &Path) -> bool {
955 if let Some(ext) = path.extension() {
957 if ext == "template" {
958 return true;
959 }
960 if let Some(ext_str) = ext.to_str() {
962 return TEMPLATE_EXTENSIONS.contains(&ext_str);
963 }
964 }
965 if let Some(stem) = path.file_stem() {
967 let stem_path = Path::new(stem);
968 if let Some(ext) = stem_path.extension()
969 && let Some(ext_str) = ext.to_str()
970 {
971 return TEMPLATE_EXTENSIONS.contains(&ext_str);
972 }
973 }
974 false
975}
976
977fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
979 let mut pos = 0;
981 let mut unreplaced = Vec::new();
982
983 while let Some(start) = content[pos..].find("{{") {
984 let abs_start = pos + start;
985 if let Some(end) = content[abs_start..].find("}}") {
986 let placeholder = &content[abs_start..abs_start + end + 2];
987 let var_name = &content[abs_start + 2..abs_start + end];
989 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
992 unreplaced.push(placeholder.to_string());
993 }
994 pos = abs_start + end + 2;
995 } else {
996 break;
997 }
998 }
999
1000 if !unreplaced.is_empty() {
1001 return Err(BenchError::Build(format!(
1002 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
1003 This is a bug in mobench-sdk. Please report it at:\n\
1004 https://github.com/worldcoin/mobile-bench-rs/issues",
1005 file_path, unreplaced
1006 )));
1007 }
1008
1009 Ok(())
1010}
1011
1012fn render_template(input: &str, vars: &[TemplateVar]) -> String {
1013 let mut output = input.to_string();
1014 for var in vars {
1015 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
1016 }
1017 output
1018}
1019
1020pub fn sanitize_bundle_id_component(name: &str) -> String {
1031 name.chars()
1032 .filter(|c| c.is_ascii_alphanumeric())
1033 .collect::<String>()
1034 .to_lowercase()
1035}
1036
1037fn sanitize_package_name(name: &str) -> String {
1038 name.chars()
1039 .map(|c| {
1040 if c.is_ascii_alphanumeric() {
1041 c.to_ascii_lowercase()
1042 } else {
1043 '-'
1044 }
1045 })
1046 .collect::<String>()
1047 .trim_matches('-')
1048 .replace("--", "-")
1049}
1050
1051pub fn to_pascal_case(input: &str) -> String {
1053 input
1054 .split(|c: char| !c.is_ascii_alphanumeric())
1055 .filter(|s| !s.is_empty())
1056 .map(|s| {
1057 let mut chars = s.chars();
1058 let first = chars.next().unwrap().to_ascii_uppercase();
1059 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
1060 format!("{}{}", first, rest)
1061 })
1062 .collect::<String>()
1063}
1064
1065pub fn android_project_exists(output_dir: &Path) -> bool {
1069 let android_dir = output_dir.join("android");
1070 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
1071}
1072
1073pub fn ios_project_exists(output_dir: &Path) -> bool {
1077 output_dir.join("ios/BenchRunner/project.yml").exists()
1078}
1079
1080fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1085 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
1086 let Ok(content) = std::fs::read_to_string(&project_yml) else {
1087 return false;
1088 };
1089 let expected = format!("../{}.xcframework", library_name);
1090 content.contains(&expected)
1091}
1092
1093fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1098 let build_gradle = output_dir.join("android/app/build.gradle");
1099 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
1100 return false;
1101 };
1102 let expected = format!("lib{}.so", library_name);
1103 content.contains(&expected)
1104}
1105
1106pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
1121 let lib_rs = crate_dir.join("src/lib.rs");
1122 if !lib_rs.exists() {
1123 return None;
1124 }
1125
1126 let file = fs::File::open(&lib_rs).ok()?;
1127 let reader = BufReader::new(file);
1128
1129 let mut found_benchmark_attr = false;
1130 let crate_name_normalized = crate_name.replace('-', "_");
1131
1132 for line in reader.lines().map_while(Result::ok) {
1133 let trimmed = line.trim();
1134
1135 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1137 found_benchmark_attr = true;
1138 continue;
1139 }
1140
1141 if found_benchmark_attr {
1143 if let Some(fn_pos) = trimmed.find("fn ") {
1145 let after_fn = &trimmed[fn_pos + 3..];
1146 let fn_name: String = after_fn
1148 .chars()
1149 .take_while(|c| c.is_alphanumeric() || *c == '_')
1150 .collect();
1151
1152 if !fn_name.is_empty() {
1153 return Some(format!("{}::{}", crate_name_normalized, fn_name));
1154 }
1155 }
1156 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1159 found_benchmark_attr = false;
1160 }
1161 }
1162 }
1163
1164 None
1165}
1166
1167pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1181 let lib_rs = crate_dir.join("src/lib.rs");
1182 if !lib_rs.exists() {
1183 return Vec::new();
1184 }
1185
1186 let Ok(file) = fs::File::open(&lib_rs) else {
1187 return Vec::new();
1188 };
1189 let reader = BufReader::new(file);
1190
1191 let mut benchmarks = Vec::new();
1192 let mut found_benchmark_attr = false;
1193 let crate_name_normalized = crate_name.replace('-', "_");
1194
1195 for line in reader.lines().map_while(Result::ok) {
1196 let trimmed = line.trim();
1197
1198 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1200 found_benchmark_attr = true;
1201 continue;
1202 }
1203
1204 if found_benchmark_attr {
1206 if let Some(fn_pos) = trimmed.find("fn ") {
1208 let after_fn = &trimmed[fn_pos + 3..];
1209 let fn_name: String = after_fn
1211 .chars()
1212 .take_while(|c| c.is_alphanumeric() || *c == '_')
1213 .collect();
1214
1215 if !fn_name.is_empty() {
1216 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1217 }
1218 found_benchmark_attr = false;
1219 }
1220 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1223 found_benchmark_attr = false;
1224 }
1225 }
1226 }
1227
1228 benchmarks
1229}
1230
1231pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1243 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1244 let crate_name_normalized = crate_name.replace('-', "_");
1245
1246 let normalized_name = if function_name.contains("::") {
1248 function_name.to_string()
1249 } else {
1250 format!("{}::{}", crate_name_normalized, function_name)
1251 };
1252
1253 benchmarks.iter().any(|b| b == &normalized_name)
1254}
1255
1256pub fn resolve_default_function(
1271 project_root: &Path,
1272 crate_name: &str,
1273 crate_dir: Option<&Path>,
1274) -> String {
1275 let crate_name_normalized = crate_name.replace('-', "_");
1276
1277 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1279 vec![dir.to_path_buf()]
1280 } else {
1281 vec![
1282 project_root.join("bench-mobile"),
1283 project_root.join("crates").join(crate_name),
1284 project_root.to_path_buf(),
1285 ]
1286 };
1287
1288 for dir in &search_dirs {
1290 if dir.join("Cargo.toml").exists()
1291 && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1292 {
1293 return detected;
1294 }
1295 }
1296
1297 format!("{}::example_benchmark", crate_name_normalized)
1299}
1300
1301pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1312 ensure_android_project_with_options(output_dir, crate_name, None, None)
1313}
1314
1315pub fn ensure_android_project_with_options(
1327 output_dir: &Path,
1328 crate_name: &str,
1329 project_root: Option<&Path>,
1330 crate_dir: Option<&Path>,
1331) -> Result<(), BenchError> {
1332 ensure_android_project_with_backend_options(
1333 output_dir,
1334 crate_name,
1335 project_root,
1336 crate_dir,
1337 crate::FfiBackend::Uniffi,
1338 )
1339}
1340
1341pub fn ensure_android_project_with_backend_options(
1343 output_dir: &Path,
1344 crate_name: &str,
1345 project_root: Option<&Path>,
1346 crate_dir: Option<&Path>,
1347 ffi_backend: crate::FfiBackend,
1348) -> Result<(), BenchError> {
1349 let library_name = crate_name.replace('-', "_");
1350 if android_project_exists(output_dir)
1351 && android_project_matches_library(output_dir, &library_name)
1352 {
1353 return Ok(());
1354 }
1355
1356 println!(
1357 "Android project not found, generating scaffolding for {} backend...",
1358 ffi_backend
1359 );
1360 let project_slug = crate_name.replace('-', "_");
1361
1362 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1364 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1365
1366 generate_android_project_with_backend(
1367 output_dir,
1368 &project_slug,
1369 &default_function,
1370 ffi_backend,
1371 )?;
1372 println!(
1373 " Generated Android project at {:?}",
1374 output_dir.join("android")
1375 );
1376 println!(" Default benchmark function: {}", default_function);
1377 Ok(())
1378}
1379
1380pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1391 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1392}
1393
1394pub fn ensure_ios_project_with_options(
1406 output_dir: &Path,
1407 crate_name: &str,
1408 project_root: Option<&Path>,
1409 crate_dir: Option<&Path>,
1410) -> Result<(), BenchError> {
1411 ensure_ios_project_with_backend_options(
1412 output_dir,
1413 crate_name,
1414 project_root,
1415 crate_dir,
1416 crate::FfiBackend::Uniffi,
1417 )
1418}
1419
1420pub fn ensure_ios_project_with_backend_options(
1422 output_dir: &Path,
1423 crate_name: &str,
1424 project_root: Option<&Path>,
1425 crate_dir: Option<&Path>,
1426 ffi_backend: crate::FfiBackend,
1427) -> Result<(), BenchError> {
1428 let library_name = crate_name.replace('-', "_");
1429 let project_exists = ios_project_exists(output_dir);
1430 let project_matches = ios_project_matches_library(output_dir, &library_name);
1431 if project_exists && !project_matches {
1432 println!(
1433 "Existing iOS scaffolding does not match library, regenerating for {} backend...",
1434 ffi_backend
1435 );
1436 } else if project_exists {
1437 println!(
1438 "Refreshing generated iOS scaffolding for {} backend...",
1439 ffi_backend
1440 );
1441 } else {
1442 println!(
1443 "iOS project not found, generating scaffolding for {} backend...",
1444 ffi_backend
1445 );
1446 }
1447
1448 let project_pascal = "BenchRunner";
1450 let library_name = crate_name.replace('-', "_");
1452 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1455 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1456
1457 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1459 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1460
1461 generate_ios_project_with_backend(
1462 output_dir,
1463 &library_name,
1464 project_pascal,
1465 &bundle_prefix,
1466 &default_function,
1467 ffi_backend,
1468 )?;
1469 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1470 println!(" Default benchmark function: {}", default_function);
1471 Ok(())
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476 use super::*;
1477 use std::env;
1478
1479 #[test]
1480 fn test_generate_bench_mobile_crate() {
1481 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1482 fs::create_dir_all(&temp_dir).unwrap();
1483
1484 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1485 assert!(result.is_ok());
1486
1487 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1489 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1490 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1491 let cargo_toml =
1492 fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
1493 assert!(
1494 cargo_toml.contains(
1495 r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
1496 ),
1497 "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
1498 );
1499
1500 fs::remove_dir_all(&temp_dir).ok();
1502 }
1503
1504 #[test]
1505 fn test_generate_android_project_no_unreplaced_placeholders() {
1506 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1507 let _ = fs::remove_dir_all(&temp_dir);
1509 fs::create_dir_all(&temp_dir).unwrap();
1510
1511 let result =
1512 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1513 assert!(
1514 result.is_ok(),
1515 "generate_android_project failed: {:?}",
1516 result.err()
1517 );
1518
1519 let android_dir = temp_dir.join("android");
1521 assert!(android_dir.join("settings.gradle").exists());
1522 assert!(android_dir.join("app/build.gradle").exists());
1523 assert!(
1524 android_dir
1525 .join("app/src/main/AndroidManifest.xml")
1526 .exists()
1527 );
1528 assert!(
1529 android_dir
1530 .join("app/src/main/res/values/strings.xml")
1531 .exists()
1532 );
1533 assert!(
1534 android_dir
1535 .join("app/src/main/res/values/themes.xml")
1536 .exists()
1537 );
1538
1539 let files_to_check = [
1541 "settings.gradle",
1542 "app/build.gradle",
1543 "app/src/main/AndroidManifest.xml",
1544 "app/src/main/res/values/strings.xml",
1545 "app/src/main/res/values/themes.xml",
1546 ];
1547
1548 for file in files_to_check {
1549 let path = android_dir.join(file);
1550 let contents =
1551 fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1552
1553 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1555 assert!(
1556 !has_placeholder,
1557 "File {} contains unreplaced template placeholders: {}",
1558 file, contents
1559 );
1560 }
1561
1562 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1564 assert!(
1565 settings.contains("my-bench-project-android")
1566 || settings.contains("my_bench_project-android"),
1567 "settings.gradle should contain project name"
1568 );
1569
1570 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1571 assert!(
1573 build_gradle.contains("dev.world.mybenchproject"),
1574 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1575 );
1576 assert!(
1577 !build_gradle.contains("testBuildType \"release\""),
1578 "debug builds should be able to produce assembleDebugAndroidTest"
1579 );
1580 assert!(
1581 build_gradle.contains("mobenchTestBuildType"),
1582 "release builds should be able to request assembleReleaseAndroidTest"
1583 );
1584
1585 let manifest =
1586 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1587 assert!(
1588 manifest.contains("Theme.MyBenchProject"),
1589 "AndroidManifest.xml should contain PascalCase theme name"
1590 );
1591
1592 let strings =
1593 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1594 assert!(
1595 strings.contains("Benchmark"),
1596 "strings.xml should contain app name with Benchmark"
1597 );
1598
1599 let main_activity_path =
1602 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1603 assert!(
1604 main_activity_path.exists(),
1605 "MainActivity.kt should be in package directory: {:?}",
1606 main_activity_path
1607 );
1608
1609 let test_activity_path = android_dir
1610 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1611 assert!(
1612 test_activity_path.exists(),
1613 "MainActivityTest.kt should be in package directory: {:?}",
1614 test_activity_path
1615 );
1616
1617 assert!(
1619 !android_dir
1620 .join("app/src/main/java/MainActivity.kt")
1621 .exists(),
1622 "MainActivity.kt should not be in root java directory"
1623 );
1624 assert!(
1625 !android_dir
1626 .join("app/src/androidTest/java/MainActivityTest.kt")
1627 .exists(),
1628 "MainActivityTest.kt should not be in root java directory"
1629 );
1630
1631 fs::remove_dir_all(&temp_dir).ok();
1633 }
1634
1635 #[test]
1636 fn test_generate_android_project_replaces_previous_package_tree() {
1637 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1638 let _ = fs::remove_dir_all(&temp_dir);
1639 fs::create_dir_all(&temp_dir).unwrap();
1640
1641 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1642 .unwrap();
1643 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1644 assert!(
1645 old_package_dir.exists(),
1646 "expected first package tree to exist"
1647 );
1648
1649 generate_android_project(
1650 &temp_dir,
1651 "basic_benchmark",
1652 "basic_benchmark::bench_fibonacci",
1653 )
1654 .unwrap();
1655
1656 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1657 assert!(
1658 new_package_dir.exists(),
1659 "expected new package tree to exist"
1660 );
1661 assert!(
1662 !old_package_dir.exists(),
1663 "old package tree should be removed when regenerating the Android scaffold"
1664 );
1665
1666 fs::remove_dir_all(&temp_dir).ok();
1667 }
1668
1669 #[test]
1670 fn test_generate_android_native_backend_runner_template() {
1671 let temp_dir = env::temp_dir().join("mobench-sdk-android-native-test");
1672 let _ = fs::remove_dir_all(&temp_dir);
1673 fs::create_dir_all(&temp_dir).unwrap();
1674
1675 generate_android_project_with_backend(
1676 &temp_dir,
1677 "native_benchmark",
1678 "native_benchmark::bench_prove",
1679 crate::FfiBackend::NativeCAbi,
1680 )
1681 .unwrap();
1682
1683 let main_activity = fs::read_to_string(
1684 temp_dir.join("android/app/src/main/java/dev/world/nativebenchmark/MainActivity.kt"),
1685 )
1686 .unwrap();
1687 assert!(main_activity.contains("com.sun.jna.Native"));
1688 assert!(main_activity.contains("mobench_run_benchmark_json"));
1689 assert!(main_activity.contains("BENCH_JSON"));
1690 assert!(main_activity.contains("bench_spec.json"));
1691 assert!(
1692 !main_activity.contains("uniffi."),
1693 "native Android runner must not import UniFFI bindings:\n{}",
1694 main_activity
1695 );
1696 assert!(
1697 !main_activity.contains("runBenchmark("),
1698 "native Android runner must call the JSON C ABI, not UniFFI runBenchmark"
1699 );
1700
1701 fs::remove_dir_all(&temp_dir).ok();
1702 }
1703
1704 #[test]
1705 fn test_is_template_file() {
1706 assert!(is_template_file(Path::new("settings.gradle")));
1707 assert!(is_template_file(Path::new("app/build.gradle")));
1708 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1709 assert!(is_template_file(Path::new("strings.xml")));
1710 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1711 assert!(is_template_file(Path::new("project.yml")));
1712 assert!(is_template_file(Path::new("Info.plist")));
1713 assert!(!is_template_file(Path::new("libfoo.so")));
1714 assert!(!is_template_file(Path::new("image.png")));
1715 }
1716
1717 #[test]
1718 fn test_mobile_templates_read_process_peak_memory_compatibly() {
1719 let android =
1720 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1721 assert!(
1722 !android.contains("sample.processPeakMemoryKb"),
1723 "Android template should not require generated bindings to expose processPeakMemoryKb"
1724 );
1725 assert!(
1726 !android.contains("it.processPeakMemoryKb"),
1727 "Android template should not require generated bindings to expose processPeakMemoryKb"
1728 );
1729 assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1730 assert!(
1731 !android.contains("sample.cpuTimeMs"),
1732 "Android template should tolerate BenchSample without cpuTimeMs"
1733 );
1734 assert!(
1735 !android.contains("sample.peakMemoryKb"),
1736 "Android template should tolerate BenchSample without peakMemoryKb"
1737 );
1738 assert!(
1739 !android.contains("report.phases"),
1740 "Android template should tolerate BenchReport without phases"
1741 );
1742 assert!(android.contains("ProcessMemorySampler"));
1743 assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1744 assert!(android.contains("/proc/self/smaps_rollup"));
1745 assert!(android.contains("class BenchmarkWorkerService : Service()"));
1746 assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1747 assert!(android.contains("startForegroundService(intent)"));
1748 assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1749 assert!(android.contains("fun isBenchmarkComplete()"));
1750 assert!(!android.contains("resultLatch.await"));
1751 assert!(android.contains("memory_process\", \"isolated_worker\""));
1752
1753 let android_test = include_str!(
1754 "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1755 );
1756 assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1757 assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1758 assert!(
1759 android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_HEARTBEAT_INTERVAL_SECS}})")
1760 );
1761 assert!(
1762 android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_BENCHMARK_TIMEOUT_SECS}})")
1763 );
1764 assert!(android_test.contains("activity.isBenchmarkComplete()"));
1765
1766 let ios_test = include_str!(
1767 "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1768 );
1769 assert!(
1770 ios_test.contains("\\\"error\\\""),
1771 "iOS XCUITest template should fail when the benchmark report is an error payload"
1772 );
1773
1774 let android_manifest =
1775 include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1776 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1777 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1778 assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1779 assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1780 assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1781
1782 let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1783 assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1784 assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1785
1786 let ios =
1787 include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1788 assert!(
1789 !ios.contains("sample.processPeakMemoryKb"),
1790 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1791 );
1792 assert!(
1793 !ios.contains(r"\.processPeakMemoryKb"),
1794 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1795 );
1796 assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1797 assert!(ios.contains("return [\n \"name\": name,"));
1798 assert!(
1799 !ios.contains("sample.cpuTimeMs"),
1800 "iOS template should tolerate BenchSample without cpuTimeMs"
1801 );
1802 assert!(
1803 !ios.contains("sample.peakMemoryKb"),
1804 "iOS template should tolerate BenchSample without peakMemoryKb"
1805 );
1806 assert!(
1807 !ios.contains("report.phases"),
1808 "iOS template should tolerate BenchReport without phases"
1809 );
1810 assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1811 assert!(ios.contains("ProcessMemorySampler"));
1812 assert!(ios.contains("currentProcessResidentMemoryKb"));
1813 assert!(ios.contains("task_info("));
1814 assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1815 assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1816 assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1817 }
1818
1819 #[test]
1820 fn generated_android_test_uses_configured_timeout_and_heartbeat() {
1821 let temp_dir = env::temp_dir().join("mobench-sdk-android-timeout-test");
1822 let _ = fs::remove_dir_all(&temp_dir);
1823 fs::create_dir_all(&temp_dir).expect("create temp dir");
1824
1825 unsafe {
1826 std::env::set_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS", "7200");
1827 std::env::set_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS", "15");
1828 }
1829 let result = generate_android_project_with_backend(
1830 &temp_dir,
1831 "my-bench-project",
1832 "sample_fns::fibonacci",
1833 crate::FfiBackend::NativeCAbi,
1834 );
1835 unsafe {
1836 std::env::remove_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS");
1837 std::env::remove_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS");
1838 }
1839 result.expect("generate Android project");
1840
1841 let android_test =
1842 fs::read_to_string(temp_dir.join(
1843 "android/app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt",
1844 ))
1845 .expect("read MainActivityTest.kt");
1846
1847 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(7200)"));
1848 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(15)"));
1849 let _ = fs::remove_dir_all(&temp_dir);
1850 }
1851
1852 #[test]
1853 fn test_validate_no_unreplaced_placeholders() {
1854 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1856
1857 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1859
1860 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1862 assert!(result.is_err());
1863 let err = result.unwrap_err().to_string();
1864 assert!(err.contains("{{NAME}}"));
1865 }
1866
1867 #[test]
1868 fn test_to_pascal_case() {
1869 assert_eq!(to_pascal_case("my-project"), "MyProject");
1870 assert_eq!(to_pascal_case("my_project"), "MyProject");
1871 assert_eq!(to_pascal_case("myproject"), "Myproject");
1872 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1873 }
1874
1875 #[test]
1876 fn test_detect_default_function_finds_benchmark() {
1877 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1878 let _ = fs::remove_dir_all(&temp_dir);
1879 fs::create_dir_all(temp_dir.join("src")).unwrap();
1880
1881 let lib_content = r#"
1883use mobench_sdk::benchmark;
1884
1885/// Some docs
1886#[benchmark]
1887fn my_benchmark_func() {
1888 // benchmark code
1889}
1890
1891fn helper_func() {}
1892"#;
1893 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1894 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1895
1896 let result = detect_default_function(&temp_dir, "my_crate");
1897 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1898
1899 fs::remove_dir_all(&temp_dir).ok();
1901 }
1902
1903 #[test]
1904 fn test_detect_default_function_no_benchmark() {
1905 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1906 let _ = fs::remove_dir_all(&temp_dir);
1907 fs::create_dir_all(temp_dir.join("src")).unwrap();
1908
1909 let lib_content = r#"
1911fn regular_function() {
1912 // no benchmark here
1913}
1914"#;
1915 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1916
1917 let result = detect_default_function(&temp_dir, "my_crate");
1918 assert!(result.is_none());
1919
1920 fs::remove_dir_all(&temp_dir).ok();
1922 }
1923
1924 #[test]
1925 fn test_detect_default_function_pub_fn() {
1926 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1927 let _ = fs::remove_dir_all(&temp_dir);
1928 fs::create_dir_all(temp_dir.join("src")).unwrap();
1929
1930 let lib_content = r#"
1932#[benchmark]
1933pub fn public_bench() {
1934 // benchmark code
1935}
1936"#;
1937 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1938
1939 let result = detect_default_function(&temp_dir, "test-crate");
1940 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1941
1942 fs::remove_dir_all(&temp_dir).ok();
1944 }
1945
1946 #[test]
1947 fn test_resolve_default_function_fallback() {
1948 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1949 let _ = fs::remove_dir_all(&temp_dir);
1950 fs::create_dir_all(&temp_dir).unwrap();
1951
1952 let result = resolve_default_function(&temp_dir, "my-crate", None);
1954 assert_eq!(result, "my_crate::example_benchmark");
1955
1956 fs::remove_dir_all(&temp_dir).ok();
1958 }
1959
1960 #[test]
1961 fn test_sanitize_bundle_id_component() {
1962 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1964 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1966 assert_eq!(
1968 sanitize_bundle_id_component("my-project_name"),
1969 "myprojectname"
1970 );
1971 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1973 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1975 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1977 assert_eq!(
1979 sanitize_bundle_id_component("My-Complex_Project-123"),
1980 "mycomplexproject123"
1981 );
1982 }
1983
1984 #[test]
1985 fn test_generate_ios_project_bundle_id_not_duplicated() {
1986 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1987 let _ = fs::remove_dir_all(&temp_dir);
1989 fs::create_dir_all(&temp_dir).unwrap();
1990
1991 let crate_name = "bench-mobile";
1993 let bundle_prefix = "dev.world.benchmobile";
1994 let project_pascal = "BenchRunner";
1995
1996 let result = generate_ios_project(
1997 &temp_dir,
1998 crate_name,
1999 project_pascal,
2000 bundle_prefix,
2001 "bench_mobile::test_func",
2002 );
2003 assert!(
2004 result.is_ok(),
2005 "generate_ios_project failed: {:?}",
2006 result.err()
2007 );
2008
2009 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
2011 assert!(project_yml_path.exists(), "project.yml should exist");
2012
2013 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
2015
2016 assert!(
2019 project_yml.contains("dev.world.benchmobile.BenchRunner"),
2020 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
2021 project_yml
2022 );
2023 assert!(
2024 !project_yml.contains("dev.world.benchmobile.benchmobile"),
2025 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
2026 project_yml
2027 );
2028 assert!(
2029 project_yml.contains("embed: false"),
2030 "Static xcframework dependency should be link-only, got:\n{}",
2031 project_yml
2032 );
2033
2034 fs::remove_dir_all(&temp_dir).ok();
2036 }
2037
2038 #[test]
2039 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
2040 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
2041 let _ = fs::remove_dir_all(&temp_dir);
2042 fs::create_dir_all(&temp_dir).unwrap();
2043
2044 generate_ios_project(
2045 &temp_dir,
2046 "bench_mobile",
2047 "BenchRunner",
2048 "dev.world.benchmobile",
2049 "bench_mobile::bench_prepare",
2050 )
2051 .unwrap();
2052
2053 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
2054 fs::create_dir_all(resources_dir.join("nested")).unwrap();
2055 fs::write(
2056 resources_dir.join("bench_spec.json"),
2057 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
2058 )
2059 .unwrap();
2060 fs::write(
2061 resources_dir.join("bench_meta.json"),
2062 r#"{"build_id":"build-123"}"#,
2063 )
2064 .unwrap();
2065 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
2066
2067 generate_ios_project(
2068 &temp_dir,
2069 "bench_mobile",
2070 "BenchRunner",
2071 "dev.world.benchmobile",
2072 "bench_mobile::bench_prepare",
2073 )
2074 .unwrap();
2075
2076 assert_eq!(
2077 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
2078 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
2079 );
2080 assert_eq!(
2081 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
2082 r#"{"build_id":"build-123"}"#
2083 );
2084 assert_eq!(
2085 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
2086 "keep me"
2087 );
2088
2089 fs::remove_dir_all(&temp_dir).ok();
2090 }
2091
2092 #[test]
2093 fn test_generate_ios_native_backend_runner_template() {
2094 let temp_dir = env::temp_dir().join("mobench-sdk-ios-native-test");
2095 let _ = fs::remove_dir_all(&temp_dir);
2096 fs::create_dir_all(&temp_dir).unwrap();
2097
2098 generate_ios_project_with_backend(
2099 &temp_dir,
2100 "native_benchmark",
2101 "BenchRunner",
2102 "dev.world.nativebenchmark",
2103 "native_benchmark::bench_prove",
2104 crate::FfiBackend::NativeCAbi,
2105 )
2106 .unwrap();
2107
2108 let ffi =
2109 fs::read_to_string(temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift"))
2110 .unwrap();
2111 assert!(ffi.contains("mobench_run_benchmark_json"));
2112 assert!(ffi.contains("mobench_free_buf"));
2113 assert!(ffi.contains("BENCH_FUNCTION"));
2114 assert!(
2115 !ffi.contains("runBenchmark(spec:"),
2116 "native iOS runner must call the JSON C ABI, not UniFFI runBenchmark"
2117 );
2118 assert!(
2119 !ffi.contains("let report: BenchReport"),
2120 "native iOS runner should operate on JSON, not UniFFI BenchReport"
2121 );
2122
2123 let header = fs::read_to_string(
2124 temp_dir.join("ios/BenchRunner/BenchRunner/Generated/native_benchmarkFFI.h"),
2125 )
2126 .unwrap();
2127 assert!(header.contains("mobench_run_benchmark_json"));
2128
2129 fs::remove_dir_all(&temp_dir).ok();
2130 }
2131
2132 #[test]
2133 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
2134 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
2135 let _ = fs::remove_dir_all(&temp_dir);
2136 fs::create_dir_all(&temp_dir).unwrap();
2137
2138 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2139 .expect("initial iOS project generation should succeed");
2140
2141 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
2142 assert!(content_view_path.exists(), "ContentView.swift should exist");
2143
2144 fs::write(&content_view_path, "stale generated content").unwrap();
2145
2146 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2147 .expect("refreshing existing iOS project should succeed");
2148
2149 let refreshed = fs::read_to_string(&content_view_path).unwrap();
2150 assert!(
2151 refreshed.contains("ProfileLaunchOptions"),
2152 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
2153 refreshed
2154 );
2155 assert!(
2156 refreshed.contains("repeatUntilMs"),
2157 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
2158 refreshed
2159 );
2160 assert!(
2161 refreshed.contains("Task.detached(priority: .userInitiated)"),
2162 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
2163 refreshed
2164 );
2165 assert!(
2166 refreshed.contains("await MainActor.run"),
2167 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
2168 refreshed
2169 );
2170
2171 fs::remove_dir_all(&temp_dir).ok();
2172 }
2173
2174 #[test]
2175 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
2176 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
2177 let _ = fs::remove_dir_all(&temp_dir);
2178 fs::create_dir_all(&temp_dir).unwrap();
2179
2180 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2181 .expect("initial iOS project generation should succeed");
2182
2183 let ui_test_path =
2184 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2185 assert!(
2186 ui_test_path.exists(),
2187 "BenchRunnerUITests.swift should exist"
2188 );
2189
2190 fs::write(&ui_test_path, "stale generated content").unwrap();
2191
2192 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2193 .expect("refreshing existing iOS project should succeed");
2194
2195 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
2196 assert!(
2197 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
2198 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
2199 refreshed
2200 );
2201 assert!(
2202 refreshed.contains(
2203 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
2204 ),
2205 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
2206 refreshed
2207 );
2208
2209 fs::remove_dir_all(&temp_dir).ok();
2210 }
2211
2212 #[test]
2213 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
2214 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
2215 let _ = fs::remove_dir_all(&temp_dir);
2216 fs::create_dir_all(&temp_dir).unwrap();
2217
2218 let result = generate_ios_project_with_timeout(
2219 &temp_dir,
2220 "sample_fns",
2221 "BenchRunner",
2222 "dev.world.samplefns",
2223 "sample_fns::example_benchmark",
2224 1200,
2225 crate::FfiBackend::Uniffi,
2226 );
2227
2228 assert!(result.is_ok(), "generate_ios_project should succeed");
2229
2230 let ui_test_path =
2231 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2232 let contents = fs::read_to_string(&ui_test_path).unwrap();
2233 assert!(
2234 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
2235 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
2236 contents
2237 );
2238
2239 fs::remove_dir_all(&temp_dir).ok();
2240 }
2241
2242 #[test]
2243 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
2244 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
2245 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
2246 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
2247 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
2248 }
2249
2250 #[test]
2251 fn test_cross_platform_naming_consistency() {
2252 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
2254 let _ = fs::remove_dir_all(&temp_dir);
2255 fs::create_dir_all(&temp_dir).unwrap();
2256
2257 let project_name = "bench-mobile";
2258
2259 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
2261 assert!(
2262 result.is_ok(),
2263 "generate_android_project failed: {:?}",
2264 result.err()
2265 );
2266
2267 let bundle_id_component = sanitize_bundle_id_component(project_name);
2269 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2270 let result = generate_ios_project(
2271 &temp_dir,
2272 &project_name.replace('-', "_"),
2273 "BenchRunner",
2274 &bundle_prefix,
2275 "bench_mobile::test_func",
2276 );
2277 assert!(
2278 result.is_ok(),
2279 "generate_ios_project failed: {:?}",
2280 result.err()
2281 );
2282
2283 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2285 .expect("Failed to read Android build.gradle");
2286
2287 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2289 .expect("Failed to read iOS project.yml");
2290
2291 assert!(
2295 android_build_gradle.contains("dev.world.benchmobile"),
2296 "Android package should be 'dev.world.benchmobile', got:\n{}",
2297 android_build_gradle
2298 );
2299 assert!(
2300 ios_project_yml.contains("dev.world.benchmobile"),
2301 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
2302 ios_project_yml
2303 );
2304
2305 assert!(
2307 !android_build_gradle.contains("dev.world.bench-mobile"),
2308 "Android package should NOT contain hyphens"
2309 );
2310 assert!(
2311 !android_build_gradle.contains("dev.world.bench_mobile"),
2312 "Android package should NOT contain underscores"
2313 );
2314
2315 fs::remove_dir_all(&temp_dir).ok();
2317 }
2318
2319 #[test]
2320 fn test_cross_platform_version_consistency() {
2321 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
2323 let _ = fs::remove_dir_all(&temp_dir);
2324 fs::create_dir_all(&temp_dir).unwrap();
2325
2326 let project_name = "test-project";
2327
2328 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
2330 assert!(
2331 result.is_ok(),
2332 "generate_android_project failed: {:?}",
2333 result.err()
2334 );
2335
2336 let bundle_id_component = sanitize_bundle_id_component(project_name);
2338 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2339 let result = generate_ios_project(
2340 &temp_dir,
2341 &project_name.replace('-', "_"),
2342 "BenchRunner",
2343 &bundle_prefix,
2344 "test_project::test_func",
2345 );
2346 assert!(
2347 result.is_ok(),
2348 "generate_ios_project failed: {:?}",
2349 result.err()
2350 );
2351
2352 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2354 .expect("Failed to read Android build.gradle");
2355
2356 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2358 .expect("Failed to read iOS project.yml");
2359
2360 assert!(
2362 android_build_gradle.contains("versionName \"1.0.0\""),
2363 "Android versionName should be '1.0.0', got:\n{}",
2364 android_build_gradle
2365 );
2366 assert!(
2367 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2368 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2369 ios_project_yml
2370 );
2371
2372 fs::remove_dir_all(&temp_dir).ok();
2374 }
2375
2376 #[test]
2377 fn test_bundle_id_prefix_consistency() {
2378 let test_cases = vec![
2380 ("my-project", "dev.world.myproject"),
2381 ("bench_mobile", "dev.world.benchmobile"),
2382 ("TestApp", "dev.world.testapp"),
2383 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2384 (
2385 "app_with_many_underscores",
2386 "dev.world.appwithmanyunderscores",
2387 ),
2388 ];
2389
2390 for (input, expected_prefix) in test_cases {
2391 let sanitized = sanitize_bundle_id_component(input);
2392 let full_prefix = format!("dev.world.{}", sanitized);
2393 assert_eq!(
2394 full_prefix, expected_prefix,
2395 "For input '{}', expected '{}' but got '{}'",
2396 input, expected_prefix, full_prefix
2397 );
2398 }
2399 }
2400}