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