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 DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
16
17#[derive(Debug, Clone)]
19pub struct TemplateVar {
20 pub name: &'static str,
21 pub value: String,
22}
23
24pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
41 let output_dir = &config.output_dir;
42 let project_slug = sanitize_package_name(&config.project_name);
43 let project_pascal = to_pascal_case(&project_slug);
44 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
46 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
47
48 fs::create_dir_all(output_dir)?;
50
51 generate_bench_mobile_crate(output_dir, &project_slug)?;
53
54 let default_function = "example_fibonacci";
57
58 match config.target {
60 Target::Android => {
61 generate_android_project(output_dir, &project_slug, default_function)?;
62 }
63 Target::Ios => {
64 generate_ios_project(
65 output_dir,
66 &project_slug,
67 &project_pascal,
68 &bundle_prefix,
69 default_function,
70 )?;
71 }
72 Target::Both => {
73 generate_android_project(output_dir, &project_slug, default_function)?;
74 generate_ios_project(
75 output_dir,
76 &project_slug,
77 &project_pascal,
78 &bundle_prefix,
79 default_function,
80 )?;
81 }
82 }
83
84 generate_config_file(output_dir, config)?;
86
87 if config.generate_examples {
89 generate_example_benchmarks(output_dir)?;
90 }
91
92 Ok(output_dir.clone())
93}
94
95fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
97 let crate_dir = output_dir.join("bench-mobile");
98 fs::create_dir_all(crate_dir.join("src"))?;
99
100 let crate_name = format!("{}-bench-mobile", project_name);
101
102 let cargo_toml = format!(
106 r#"[package]
107name = "{}"
108version = "0.1.0"
109edition = "2021"
110
111[lib]
112crate-type = ["cdylib", "staticlib", "rlib"]
113
114[dependencies]
115mobench-sdk = {{ path = ".." }}
116uniffi = "0.28"
117{} = {{ path = ".." }}
118
119[features]
120default = []
121
122[build-dependencies]
123uniffi = {{ version = "0.28", features = ["build"] }}
124
125# Binary for generating UniFFI bindings (used by mobench build)
126[[bin]]
127name = "uniffi-bindgen"
128path = "src/bin/uniffi-bindgen.rs"
129
130# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
131# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
132# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
133#
134# Add this to your root Cargo.toml:
135# [workspace.dependencies]
136# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
137#
138# Then in each crate that uses rustls:
139# [dependencies]
140# rustls = {{ workspace = true }}
141"#,
142 crate_name, project_name
143 );
144
145 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
146
147 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
149//!
150//! This crate provides the FFI boundary between Rust benchmarks and mobile
151//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
152
153use uniffi;
154
155// Ensure the user crate is linked so benchmark registrations are pulled in.
156extern crate {{USER_CRATE}} as _bench_user_crate;
157
158// Re-export mobench-sdk types with UniFFI annotations
159#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
160pub struct BenchSpec {
161 pub name: String,
162 pub iterations: u32,
163 pub warmup: u32,
164}
165
166#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
167pub struct BenchSample {
168 pub duration_ns: u64,
169 pub cpu_time_ms: Option<u64>,
170 pub peak_memory_kb: Option<u64>,
171 pub process_peak_memory_kb: Option<u64>,
172}
173
174#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
175pub struct SemanticPhase {
176 pub name: String,
177 pub duration_ns: u64,
178}
179
180#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
181pub struct HarnessTimelineSpan {
182 pub phase: String,
183 pub start_offset_ns: u64,
184 pub end_offset_ns: u64,
185 pub iteration: Option<u32>,
186}
187
188#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
189pub struct BenchReport {
190 pub spec: BenchSpec,
191 pub samples: Vec<BenchSample>,
192 pub phases: Vec<SemanticPhase>,
193 pub timeline: Vec<HarnessTimelineSpan>,
194}
195
196#[derive(Debug, thiserror::Error, uniffi::Error)]
197#[uniffi(flat_error)]
198pub enum BenchError {
199 #[error("iterations must be greater than zero")]
200 InvalidIterations,
201
202 #[error("unknown benchmark function: {name}")]
203 UnknownFunction { name: String },
204
205 #[error("benchmark execution failed: {reason}")]
206 ExecutionFailed { reason: String },
207}
208
209// Convert from mobench-sdk types
210impl From<mobench_sdk::BenchSpec> for BenchSpec {
211 fn from(spec: mobench_sdk::BenchSpec) -> Self {
212 Self {
213 name: spec.name,
214 iterations: spec.iterations,
215 warmup: spec.warmup,
216 }
217 }
218}
219
220impl From<BenchSpec> for mobench_sdk::BenchSpec {
221 fn from(spec: BenchSpec) -> Self {
222 Self {
223 name: spec.name,
224 iterations: spec.iterations,
225 warmup: spec.warmup,
226 }
227 }
228}
229
230impl From<mobench_sdk::BenchSample> for BenchSample {
231 fn from(sample: mobench_sdk::BenchSample) -> Self {
232 Self {
233 duration_ns: sample.duration_ns,
234 cpu_time_ms: sample.cpu_time_ms,
235 peak_memory_kb: sample.peak_memory_kb,
236 process_peak_memory_kb: sample.process_peak_memory_kb,
237 }
238 }
239}
240
241impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
242 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
243 Self {
244 name: phase.name,
245 duration_ns: phase.duration_ns,
246 }
247 }
248}
249
250impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
251 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
252 Self {
253 phase: span.phase,
254 start_offset_ns: span.start_offset_ns,
255 end_offset_ns: span.end_offset_ns,
256 iteration: span.iteration,
257 }
258 }
259}
260
261impl From<mobench_sdk::RunnerReport> for BenchReport {
262 fn from(report: mobench_sdk::RunnerReport) -> Self {
263 Self {
264 spec: report.spec.into(),
265 samples: report.samples.into_iter().map(Into::into).collect(),
266 phases: report.phases.into_iter().map(Into::into).collect(),
267 timeline: report.timeline.into_iter().map(Into::into).collect(),
268 }
269 }
270}
271
272impl From<mobench_sdk::BenchError> for BenchError {
273 fn from(err: mobench_sdk::BenchError) -> Self {
274 match err {
275 mobench_sdk::BenchError::Runner(runner_err) => {
276 BenchError::ExecutionFailed {
277 reason: runner_err.to_string(),
278 }
279 }
280 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
281 BenchError::UnknownFunction { name }
282 }
283 _ => BenchError::ExecutionFailed {
284 reason: err.to_string(),
285 },
286 }
287 }
288}
289
290/// Runs a benchmark by name with the given specification
291///
292/// This is the main FFI entry point called from mobile platforms.
293#[uniffi::export]
294pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
295 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
296 let report = mobench_sdk::run_benchmark(sdk_spec)?;
297 Ok(report.into())
298}
299
300// Generate UniFFI scaffolding
301uniffi::setup_scaffolding!();
302"#;
303
304 let lib_rs = render_template(
305 lib_rs_template,
306 &[TemplateVar {
307 name: "USER_CRATE",
308 value: project_name.replace('-', "_"),
309 }],
310 );
311 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
312
313 let build_rs = r#"fn main() {
315 uniffi::generate_scaffolding("src/lib.rs").unwrap();
316}
317"#;
318
319 fs::write(crate_dir.join("build.rs"), build_rs)?;
320
321 let bin_dir = crate_dir.join("src/bin");
323 fs::create_dir_all(&bin_dir)?;
324 let uniffi_bindgen_rs = r#"fn main() {
325 uniffi::uniffi_bindgen_main()
326}
327"#;
328 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
329
330 Ok(())
331}
332
333pub fn generate_android_project(
344 output_dir: &Path,
345 project_slug: &str,
346 default_function: &str,
347) -> Result<(), BenchError> {
348 let target_dir = output_dir.join("android");
349 reset_generated_project_dir(&target_dir)?;
350 let library_name = project_slug.replace('-', "_");
351 let project_pascal = to_pascal_case(project_slug);
352 let package_id_component = sanitize_bundle_id_component(project_slug);
355 let package_name = format!("dev.world.{}", package_id_component);
356 let vars = vec![
357 TemplateVar {
358 name: "PROJECT_NAME",
359 value: project_slug.to_string(),
360 },
361 TemplateVar {
362 name: "PROJECT_NAME_PASCAL",
363 value: project_pascal.clone(),
364 },
365 TemplateVar {
366 name: "APP_NAME",
367 value: format!("{} Benchmark", project_pascal),
368 },
369 TemplateVar {
370 name: "PACKAGE_NAME",
371 value: package_name.clone(),
372 },
373 TemplateVar {
374 name: "UNIFFI_NAMESPACE",
375 value: library_name.clone(),
376 },
377 TemplateVar {
378 name: "LIBRARY_NAME",
379 value: library_name,
380 },
381 TemplateVar {
382 name: "DEFAULT_FUNCTION",
383 value: default_function.to_string(),
384 },
385 ];
386 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
387
388 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
391
392 Ok(())
393}
394
395fn collect_preserved_files(
396 root: &Path,
397 current: &Path,
398 preserved: &mut Vec<(PathBuf, Vec<u8>)>,
399) -> Result<(), BenchError> {
400 let mut entries = fs::read_dir(current)?
401 .collect::<Result<Vec<_>, _>>()
402 .map_err(BenchError::Io)?;
403 entries.sort_by_key(|entry| entry.path());
404
405 for entry in entries {
406 let path = entry.path();
407 if path.is_dir() {
408 collect_preserved_files(root, &path, preserved)?;
409 continue;
410 }
411
412 let relative = path.strip_prefix(root).map_err(|e| {
413 BenchError::Build(format!(
414 "Failed to preserve generated resource {:?}: {}",
415 path, e
416 ))
417 })?;
418 preserved.push((relative.to_path_buf(), fs::read(&path)?));
419 }
420
421 Ok(())
422}
423
424fn collect_preserved_ios_resources(
425 target_dir: &Path,
426) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
427 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
428 let mut preserved = Vec::new();
429
430 if resources_dir.exists() {
431 collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
432 }
433
434 Ok(preserved)
435}
436
437fn restore_preserved_ios_resources(
438 target_dir: &Path,
439 preserved_resources: &[(PathBuf, Vec<u8>)],
440) -> Result<(), BenchError> {
441 if preserved_resources.is_empty() {
442 return Ok(());
443 }
444
445 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
446 for (relative, contents) in preserved_resources {
447 let resource_path = resources_dir.join(relative);
448 if let Some(parent) = resource_path.parent() {
449 fs::create_dir_all(parent)?;
450 }
451 fs::write(resource_path, contents)?;
452 }
453
454 Ok(())
455}
456
457fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
458 if target_dir.exists() {
459 fs::remove_dir_all(target_dir).map_err(|e| {
460 BenchError::Build(format!(
461 "Failed to clear existing generated project at {:?}: {}",
462 target_dir, e
463 ))
464 })?;
465 }
466 Ok(())
467}
468
469fn move_kotlin_files_to_package_dir(
479 android_dir: &Path,
480 package_name: &str,
481) -> Result<(), BenchError> {
482 let package_path = package_name.replace('.', "/");
484
485 let main_java_dir = android_dir.join("app/src/main/java");
487 let main_package_dir = main_java_dir.join(&package_path);
488 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
489
490 let test_java_dir = android_dir.join("app/src/androidTest/java");
492 let test_package_dir = test_java_dir.join(&package_path);
493 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
494
495 Ok(())
496}
497
498fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
500 let src_file = src_dir.join(filename);
501 if !src_file.exists() {
502 return Ok(());
504 }
505
506 fs::create_dir_all(dest_dir).map_err(|e| {
508 BenchError::Build(format!(
509 "Failed to create package directory {:?}: {}",
510 dest_dir, e
511 ))
512 })?;
513
514 let dest_file = dest_dir.join(filename);
515
516 fs::copy(&src_file, &dest_file).map_err(|e| {
518 BenchError::Build(format!(
519 "Failed to copy {} to {:?}: {}",
520 filename, dest_file, e
521 ))
522 })?;
523
524 fs::remove_file(&src_file).map_err(|e| {
525 BenchError::Build(format!(
526 "Failed to remove original file {:?}: {}",
527 src_file, e
528 ))
529 })?;
530
531 Ok(())
532}
533
534pub fn generate_ios_project(
547 output_dir: &Path,
548 project_slug: &str,
549 project_pascal: &str,
550 bundle_prefix: &str,
551 default_function: &str,
552) -> Result<(), BenchError> {
553 let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
554 std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
555 .ok()
556 .as_deref(),
557 );
558 generate_ios_project_with_timeout(
559 output_dir,
560 project_slug,
561 project_pascal,
562 bundle_prefix,
563 default_function,
564 ios_benchmark_timeout_secs,
565 )
566}
567
568fn generate_ios_project_with_timeout(
569 output_dir: &Path,
570 project_slug: &str,
571 project_pascal: &str,
572 bundle_prefix: &str,
573 default_function: &str,
574 ios_benchmark_timeout_secs: u64,
575) -> Result<(), BenchError> {
576 let target_dir = output_dir.join("ios");
577 let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
578 reset_generated_project_dir(&target_dir)?;
579 let sanitized_bundle_prefix = {
582 let parts: Vec<&str> = bundle_prefix.split('.').collect();
583 parts
584 .iter()
585 .map(|part| sanitize_bundle_id_component(part))
586 .collect::<Vec<_>>()
587 .join(".")
588 };
589 let vars = vec![
593 TemplateVar {
594 name: "DEFAULT_FUNCTION",
595 value: default_function.to_string(),
596 },
597 TemplateVar {
598 name: "PROJECT_NAME_PASCAL",
599 value: project_pascal.to_string(),
600 },
601 TemplateVar {
602 name: "BUNDLE_ID_PREFIX",
603 value: sanitized_bundle_prefix.clone(),
604 },
605 TemplateVar {
606 name: "BUNDLE_ID",
607 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
608 },
609 TemplateVar {
610 name: "LIBRARY_NAME",
611 value: project_slug.replace('-', "_"),
612 },
613 TemplateVar {
614 name: "IOS_BENCHMARK_TIMEOUT_SECS",
615 value: ios_benchmark_timeout_secs.to_string(),
616 },
617 ];
618 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
619 restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
620 Ok(())
621}
622
623fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
624 value
625 .and_then(|raw| raw.parse::<u64>().ok())
626 .filter(|secs| *secs > 0)
627 .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
628}
629
630fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
632 let config_target = match config.target {
633 Target::Ios => "ios",
634 Target::Android | Target::Both => "android",
635 };
636 let config_content = format!(
637 r#"# mobench configuration
638# This file controls how benchmarks are executed on devices.
639
640target = "{}"
641function = "example_fibonacci"
642iterations = 100
643warmup = 10
644device_matrix = "device-matrix.yaml"
645device_tags = ["default"]
646
647[browserstack]
648app_automate_username = "${{BROWSERSTACK_USERNAME}}"
649app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
650project = "{}-benchmarks"
651
652[ios_xcuitest]
653app = "target/ios/BenchRunner.ipa"
654test_suite = "target/ios/BenchRunnerUITests.zip"
655"#,
656 config_target, config.project_name
657 );
658
659 fs::write(output_dir.join("bench-config.toml"), config_content)?;
660
661 Ok(())
662}
663
664fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
666 let examples_dir = output_dir.join("benches");
667 fs::create_dir_all(&examples_dir)?;
668
669 let example_content = r#"//! Example benchmarks
670//!
671//! This file demonstrates how to write benchmarks with mobench-sdk.
672
673use mobench_sdk::benchmark;
674
675/// Simple benchmark example
676#[benchmark]
677fn example_fibonacci() {
678 let result = fibonacci(30);
679 std::hint::black_box(result);
680}
681
682/// Another example with a loop
683#[benchmark]
684fn example_sum() {
685 let mut sum = 0u64;
686 for i in 0..10000 {
687 sum = sum.wrapping_add(i);
688 }
689 std::hint::black_box(sum);
690}
691
692// Helper function (not benchmarked)
693fn fibonacci(n: u32) -> u64 {
694 match n {
695 0 => 0,
696 1 => 1,
697 _ => {
698 let mut a = 0u64;
699 let mut b = 1u64;
700 for _ in 2..=n {
701 let next = a.wrapping_add(b);
702 a = b;
703 b = next;
704 }
705 b
706 }
707 }
708}
709"#;
710
711 fs::write(examples_dir.join("example.rs"), example_content)?;
712
713 Ok(())
714}
715
716const TEMPLATE_EXTENSIONS: &[&str] = &[
718 "gradle",
719 "xml",
720 "kt",
721 "java",
722 "swift",
723 "yml",
724 "yaml",
725 "json",
726 "toml",
727 "md",
728 "txt",
729 "h",
730 "m",
731 "plist",
732 "pbxproj",
733 "xcscheme",
734 "xcworkspacedata",
735 "entitlements",
736 "modulemap",
737];
738
739fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
740 for entry in dir.entries() {
741 match entry {
742 DirEntry::Dir(sub) => {
743 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
745 continue;
746 }
747 render_dir(sub, out_root, vars)?;
748 }
749 DirEntry::File(file) => {
750 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
751 continue;
752 }
753 let mut relative = file.path().to_path_buf();
755 let mut contents = file.contents().to_vec();
756
757 let is_explicit_template = relative
759 .extension()
760 .map(|ext| ext == "template")
761 .unwrap_or(false);
762
763 let should_render = is_explicit_template || is_template_file(&relative);
765
766 if is_explicit_template {
767 relative.set_extension("");
769 }
770
771 if should_render {
772 if let Ok(text) = std::str::from_utf8(&contents) {
773 let rendered = render_template(text, vars);
774 validate_no_unreplaced_placeholders(&rendered, &relative)?;
776 contents = rendered.into_bytes();
777 }
778 }
779
780 let out_path = out_root.join(relative);
781 if let Some(parent) = out_path.parent() {
782 fs::create_dir_all(parent)?;
783 }
784 fs::write(&out_path, contents)?;
785 }
786 }
787 }
788 Ok(())
789}
790
791fn is_template_file(path: &Path) -> bool {
794 if let Some(ext) = path.extension() {
796 if ext == "template" {
797 return true;
798 }
799 if let Some(ext_str) = ext.to_str() {
801 return TEMPLATE_EXTENSIONS.contains(&ext_str);
802 }
803 }
804 if let Some(stem) = path.file_stem() {
806 let stem_path = Path::new(stem);
807 if let Some(ext) = stem_path.extension() {
808 if let Some(ext_str) = ext.to_str() {
809 return TEMPLATE_EXTENSIONS.contains(&ext_str);
810 }
811 }
812 }
813 false
814}
815
816fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
818 let mut pos = 0;
820 let mut unreplaced = Vec::new();
821
822 while let Some(start) = content[pos..].find("{{") {
823 let abs_start = pos + start;
824 if let Some(end) = content[abs_start..].find("}}") {
825 let placeholder = &content[abs_start..abs_start + end + 2];
826 let var_name = &content[abs_start + 2..abs_start + end];
828 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
831 unreplaced.push(placeholder.to_string());
832 }
833 pos = abs_start + end + 2;
834 } else {
835 break;
836 }
837 }
838
839 if !unreplaced.is_empty() {
840 return Err(BenchError::Build(format!(
841 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
842 This is a bug in mobench-sdk. Please report it at:\n\
843 https://github.com/worldcoin/mobile-bench-rs/issues",
844 file_path, unreplaced
845 )));
846 }
847
848 Ok(())
849}
850
851fn render_template(input: &str, vars: &[TemplateVar]) -> String {
852 let mut output = input.to_string();
853 for var in vars {
854 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
855 }
856 output
857}
858
859pub fn sanitize_bundle_id_component(name: &str) -> String {
870 name.chars()
871 .filter(|c| c.is_ascii_alphanumeric())
872 .collect::<String>()
873 .to_lowercase()
874}
875
876fn sanitize_package_name(name: &str) -> String {
877 name.chars()
878 .map(|c| {
879 if c.is_ascii_alphanumeric() {
880 c.to_ascii_lowercase()
881 } else {
882 '-'
883 }
884 })
885 .collect::<String>()
886 .trim_matches('-')
887 .replace("--", "-")
888}
889
890pub fn to_pascal_case(input: &str) -> String {
892 input
893 .split(|c: char| !c.is_ascii_alphanumeric())
894 .filter(|s| !s.is_empty())
895 .map(|s| {
896 let mut chars = s.chars();
897 let first = chars.next().unwrap().to_ascii_uppercase();
898 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
899 format!("{}{}", first, rest)
900 })
901 .collect::<String>()
902}
903
904pub fn android_project_exists(output_dir: &Path) -> bool {
908 let android_dir = output_dir.join("android");
909 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
910}
911
912pub fn ios_project_exists(output_dir: &Path) -> bool {
916 output_dir.join("ios/BenchRunner/project.yml").exists()
917}
918
919fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
924 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
925 let Ok(content) = std::fs::read_to_string(&project_yml) else {
926 return false;
927 };
928 let expected = format!("../{}.xcframework", library_name);
929 content.contains(&expected)
930}
931
932fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
937 let build_gradle = output_dir.join("android/app/build.gradle");
938 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
939 return false;
940 };
941 let expected = format!("lib{}.so", library_name);
942 content.contains(&expected)
943}
944
945pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
960 let lib_rs = crate_dir.join("src/lib.rs");
961 if !lib_rs.exists() {
962 return None;
963 }
964
965 let file = fs::File::open(&lib_rs).ok()?;
966 let reader = BufReader::new(file);
967
968 let mut found_benchmark_attr = false;
969 let crate_name_normalized = crate_name.replace('-', "_");
970
971 for line in reader.lines().map_while(Result::ok) {
972 let trimmed = line.trim();
973
974 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
976 found_benchmark_attr = true;
977 continue;
978 }
979
980 if found_benchmark_attr {
982 if let Some(fn_pos) = trimmed.find("fn ") {
984 let after_fn = &trimmed[fn_pos + 3..];
985 let fn_name: String = after_fn
987 .chars()
988 .take_while(|c| c.is_alphanumeric() || *c == '_')
989 .collect();
990
991 if !fn_name.is_empty() {
992 return Some(format!("{}::{}", crate_name_normalized, fn_name));
993 }
994 }
995 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
998 found_benchmark_attr = false;
999 }
1000 }
1001 }
1002
1003 None
1004}
1005
1006pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1020 let lib_rs = crate_dir.join("src/lib.rs");
1021 if !lib_rs.exists() {
1022 return Vec::new();
1023 }
1024
1025 let Ok(file) = fs::File::open(&lib_rs) else {
1026 return Vec::new();
1027 };
1028 let reader = BufReader::new(file);
1029
1030 let mut benchmarks = Vec::new();
1031 let mut found_benchmark_attr = false;
1032 let crate_name_normalized = crate_name.replace('-', "_");
1033
1034 for line in reader.lines().map_while(Result::ok) {
1035 let trimmed = line.trim();
1036
1037 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1039 found_benchmark_attr = true;
1040 continue;
1041 }
1042
1043 if found_benchmark_attr {
1045 if let Some(fn_pos) = trimmed.find("fn ") {
1047 let after_fn = &trimmed[fn_pos + 3..];
1048 let fn_name: String = after_fn
1050 .chars()
1051 .take_while(|c| c.is_alphanumeric() || *c == '_')
1052 .collect();
1053
1054 if !fn_name.is_empty() {
1055 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1056 }
1057 found_benchmark_attr = false;
1058 }
1059 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1062 found_benchmark_attr = false;
1063 }
1064 }
1065 }
1066
1067 benchmarks
1068}
1069
1070pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1082 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1083 let crate_name_normalized = crate_name.replace('-', "_");
1084
1085 let normalized_name = if function_name.contains("::") {
1087 function_name.to_string()
1088 } else {
1089 format!("{}::{}", crate_name_normalized, function_name)
1090 };
1091
1092 benchmarks.iter().any(|b| b == &normalized_name)
1093}
1094
1095pub fn resolve_default_function(
1110 project_root: &Path,
1111 crate_name: &str,
1112 crate_dir: Option<&Path>,
1113) -> String {
1114 let crate_name_normalized = crate_name.replace('-', "_");
1115
1116 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1118 vec![dir.to_path_buf()]
1119 } else {
1120 vec![
1121 project_root.join("bench-mobile"),
1122 project_root.join("crates").join(crate_name),
1123 project_root.to_path_buf(),
1124 ]
1125 };
1126
1127 for dir in &search_dirs {
1129 if dir.join("Cargo.toml").exists() {
1130 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
1131 return detected;
1132 }
1133 }
1134 }
1135
1136 format!("{}::example_benchmark", crate_name_normalized)
1138}
1139
1140pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1151 ensure_android_project_with_options(output_dir, crate_name, None, None)
1152}
1153
1154pub fn ensure_android_project_with_options(
1166 output_dir: &Path,
1167 crate_name: &str,
1168 project_root: Option<&Path>,
1169 crate_dir: Option<&Path>,
1170) -> Result<(), BenchError> {
1171 let library_name = crate_name.replace('-', "_");
1172 if android_project_exists(output_dir)
1173 && android_project_matches_library(output_dir, &library_name)
1174 {
1175 return Ok(());
1176 }
1177
1178 println!("Android project not found, generating scaffolding...");
1179 let project_slug = crate_name.replace('-', "_");
1180
1181 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1183 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1184
1185 generate_android_project(output_dir, &project_slug, &default_function)?;
1186 println!(
1187 " Generated Android project at {:?}",
1188 output_dir.join("android")
1189 );
1190 println!(" Default benchmark function: {}", default_function);
1191 Ok(())
1192}
1193
1194pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1205 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1206}
1207
1208pub fn ensure_ios_project_with_options(
1220 output_dir: &Path,
1221 crate_name: &str,
1222 project_root: Option<&Path>,
1223 crate_dir: Option<&Path>,
1224) -> Result<(), BenchError> {
1225 let library_name = crate_name.replace('-', "_");
1226 let project_exists = ios_project_exists(output_dir);
1227 let project_matches = ios_project_matches_library(output_dir, &library_name);
1228 if project_exists && !project_matches {
1229 println!("Existing iOS scaffolding does not match library, regenerating...");
1230 } else if project_exists {
1231 println!("Refreshing generated iOS scaffolding...");
1232 } else {
1233 println!("iOS project not found, generating scaffolding...");
1234 }
1235
1236 let project_pascal = "BenchRunner";
1238 let library_name = crate_name.replace('-', "_");
1240 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1243 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1244
1245 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1247 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1248
1249 generate_ios_project(
1250 output_dir,
1251 &library_name,
1252 project_pascal,
1253 &bundle_prefix,
1254 &default_function,
1255 )?;
1256 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1257 println!(" Default benchmark function: {}", default_function);
1258 Ok(())
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264 use std::env;
1265
1266 #[test]
1267 fn test_generate_bench_mobile_crate() {
1268 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1269 fs::create_dir_all(&temp_dir).unwrap();
1270
1271 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1272 assert!(result.is_ok());
1273
1274 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1276 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1277 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1278
1279 fs::remove_dir_all(&temp_dir).ok();
1281 }
1282
1283 #[test]
1284 fn test_generate_android_project_no_unreplaced_placeholders() {
1285 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1286 let _ = fs::remove_dir_all(&temp_dir);
1288 fs::create_dir_all(&temp_dir).unwrap();
1289
1290 let result =
1291 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1292 assert!(
1293 result.is_ok(),
1294 "generate_android_project failed: {:?}",
1295 result.err()
1296 );
1297
1298 let android_dir = temp_dir.join("android");
1300 assert!(android_dir.join("settings.gradle").exists());
1301 assert!(android_dir.join("app/build.gradle").exists());
1302 assert!(
1303 android_dir
1304 .join("app/src/main/AndroidManifest.xml")
1305 .exists()
1306 );
1307 assert!(
1308 android_dir
1309 .join("app/src/main/res/values/strings.xml")
1310 .exists()
1311 );
1312 assert!(
1313 android_dir
1314 .join("app/src/main/res/values/themes.xml")
1315 .exists()
1316 );
1317
1318 let files_to_check = [
1320 "settings.gradle",
1321 "app/build.gradle",
1322 "app/src/main/AndroidManifest.xml",
1323 "app/src/main/res/values/strings.xml",
1324 "app/src/main/res/values/themes.xml",
1325 ];
1326
1327 for file in files_to_check {
1328 let path = android_dir.join(file);
1329 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1330
1331 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1333 assert!(
1334 !has_placeholder,
1335 "File {} contains unreplaced template placeholders: {}",
1336 file, contents
1337 );
1338 }
1339
1340 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1342 assert!(
1343 settings.contains("my-bench-project-android")
1344 || settings.contains("my_bench_project-android"),
1345 "settings.gradle should contain project name"
1346 );
1347
1348 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1349 assert!(
1351 build_gradle.contains("dev.world.mybenchproject"),
1352 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1353 );
1354
1355 let manifest =
1356 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1357 assert!(
1358 manifest.contains("Theme.MyBenchProject"),
1359 "AndroidManifest.xml should contain PascalCase theme name"
1360 );
1361
1362 let strings =
1363 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1364 assert!(
1365 strings.contains("Benchmark"),
1366 "strings.xml should contain app name with Benchmark"
1367 );
1368
1369 let main_activity_path =
1372 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1373 assert!(
1374 main_activity_path.exists(),
1375 "MainActivity.kt should be in package directory: {:?}",
1376 main_activity_path
1377 );
1378
1379 let test_activity_path = android_dir
1380 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1381 assert!(
1382 test_activity_path.exists(),
1383 "MainActivityTest.kt should be in package directory: {:?}",
1384 test_activity_path
1385 );
1386
1387 assert!(
1389 !android_dir
1390 .join("app/src/main/java/MainActivity.kt")
1391 .exists(),
1392 "MainActivity.kt should not be in root java directory"
1393 );
1394 assert!(
1395 !android_dir
1396 .join("app/src/androidTest/java/MainActivityTest.kt")
1397 .exists(),
1398 "MainActivityTest.kt should not be in root java directory"
1399 );
1400
1401 fs::remove_dir_all(&temp_dir).ok();
1403 }
1404
1405 #[test]
1406 fn test_generate_android_project_replaces_previous_package_tree() {
1407 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1408 let _ = fs::remove_dir_all(&temp_dir);
1409 fs::create_dir_all(&temp_dir).unwrap();
1410
1411 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1412 .unwrap();
1413 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1414 assert!(
1415 old_package_dir.exists(),
1416 "expected first package tree to exist"
1417 );
1418
1419 generate_android_project(
1420 &temp_dir,
1421 "basic_benchmark",
1422 "basic_benchmark::bench_fibonacci",
1423 )
1424 .unwrap();
1425
1426 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1427 assert!(
1428 new_package_dir.exists(),
1429 "expected new package tree to exist"
1430 );
1431 assert!(
1432 !old_package_dir.exists(),
1433 "old package tree should be removed when regenerating the Android scaffold"
1434 );
1435
1436 fs::remove_dir_all(&temp_dir).ok();
1437 }
1438
1439 #[test]
1440 fn test_is_template_file() {
1441 assert!(is_template_file(Path::new("settings.gradle")));
1442 assert!(is_template_file(Path::new("app/build.gradle")));
1443 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1444 assert!(is_template_file(Path::new("strings.xml")));
1445 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1446 assert!(is_template_file(Path::new("project.yml")));
1447 assert!(is_template_file(Path::new("Info.plist")));
1448 assert!(!is_template_file(Path::new("libfoo.so")));
1449 assert!(!is_template_file(Path::new("image.png")));
1450 }
1451
1452 #[test]
1453 fn test_mobile_templates_read_process_peak_memory_compatibly() {
1454 let android =
1455 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1456 assert!(
1457 !android.contains("sample.processPeakMemoryKb"),
1458 "Android template should not require generated bindings to expose processPeakMemoryKb"
1459 );
1460 assert!(
1461 !android.contains("it.processPeakMemoryKb"),
1462 "Android template should not require generated bindings to expose processPeakMemoryKb"
1463 );
1464 assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1465 assert!(android.contains("ProcessMemorySampler"));
1466 assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1467 assert!(android.contains("/proc/self/smaps_rollup"));
1468 assert!(android.contains("class BenchmarkWorkerService : Service()"));
1469 assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1470 assert!(android.contains("startForegroundService(intent)"));
1471 assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1472 assert!(android.contains("fun isBenchmarkComplete()"));
1473 assert!(!android.contains("resultLatch.await"));
1474 assert!(android.contains("memory_process\", \"isolated_worker\""));
1475
1476 let android_test = include_str!(
1477 "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1478 );
1479 assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1480 assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1481 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1482 assert!(android_test.contains("activity.isBenchmarkComplete()"));
1483
1484 let android_manifest =
1485 include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1486 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1487 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1488 assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1489 assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1490 assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1491
1492 let ios =
1493 include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1494 assert!(
1495 !ios.contains("sample.processPeakMemoryKb"),
1496 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1497 );
1498 assert!(
1499 !ios.contains(r"\.processPeakMemoryKb"),
1500 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1501 );
1502 assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1503 assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1504 assert!(ios.contains("ProcessMemorySampler"));
1505 assert!(ios.contains("currentProcessResidentMemoryKb"));
1506 assert!(ios.contains("task_info("));
1507 assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1508 assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1509 assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1510 }
1511
1512 #[test]
1513 fn test_validate_no_unreplaced_placeholders() {
1514 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1516
1517 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1519
1520 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1522 assert!(result.is_err());
1523 let err = result.unwrap_err().to_string();
1524 assert!(err.contains("{{NAME}}"));
1525 }
1526
1527 #[test]
1528 fn test_to_pascal_case() {
1529 assert_eq!(to_pascal_case("my-project"), "MyProject");
1530 assert_eq!(to_pascal_case("my_project"), "MyProject");
1531 assert_eq!(to_pascal_case("myproject"), "Myproject");
1532 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1533 }
1534
1535 #[test]
1536 fn test_detect_default_function_finds_benchmark() {
1537 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1538 let _ = fs::remove_dir_all(&temp_dir);
1539 fs::create_dir_all(temp_dir.join("src")).unwrap();
1540
1541 let lib_content = r#"
1543use mobench_sdk::benchmark;
1544
1545/// Some docs
1546#[benchmark]
1547fn my_benchmark_func() {
1548 // benchmark code
1549}
1550
1551fn helper_func() {}
1552"#;
1553 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1554 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1555
1556 let result = detect_default_function(&temp_dir, "my_crate");
1557 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1558
1559 fs::remove_dir_all(&temp_dir).ok();
1561 }
1562
1563 #[test]
1564 fn test_detect_default_function_no_benchmark() {
1565 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1566 let _ = fs::remove_dir_all(&temp_dir);
1567 fs::create_dir_all(temp_dir.join("src")).unwrap();
1568
1569 let lib_content = r#"
1571fn regular_function() {
1572 // no benchmark here
1573}
1574"#;
1575 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1576
1577 let result = detect_default_function(&temp_dir, "my_crate");
1578 assert!(result.is_none());
1579
1580 fs::remove_dir_all(&temp_dir).ok();
1582 }
1583
1584 #[test]
1585 fn test_detect_default_function_pub_fn() {
1586 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1587 let _ = fs::remove_dir_all(&temp_dir);
1588 fs::create_dir_all(temp_dir.join("src")).unwrap();
1589
1590 let lib_content = r#"
1592#[benchmark]
1593pub fn public_bench() {
1594 // benchmark code
1595}
1596"#;
1597 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1598
1599 let result = detect_default_function(&temp_dir, "test-crate");
1600 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1601
1602 fs::remove_dir_all(&temp_dir).ok();
1604 }
1605
1606 #[test]
1607 fn test_resolve_default_function_fallback() {
1608 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1609 let _ = fs::remove_dir_all(&temp_dir);
1610 fs::create_dir_all(&temp_dir).unwrap();
1611
1612 let result = resolve_default_function(&temp_dir, "my-crate", None);
1614 assert_eq!(result, "my_crate::example_benchmark");
1615
1616 fs::remove_dir_all(&temp_dir).ok();
1618 }
1619
1620 #[test]
1621 fn test_sanitize_bundle_id_component() {
1622 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1624 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1626 assert_eq!(
1628 sanitize_bundle_id_component("my-project_name"),
1629 "myprojectname"
1630 );
1631 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1633 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1635 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1637 assert_eq!(
1639 sanitize_bundle_id_component("My-Complex_Project-123"),
1640 "mycomplexproject123"
1641 );
1642 }
1643
1644 #[test]
1645 fn test_generate_ios_project_bundle_id_not_duplicated() {
1646 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1647 let _ = fs::remove_dir_all(&temp_dir);
1649 fs::create_dir_all(&temp_dir).unwrap();
1650
1651 let crate_name = "bench-mobile";
1653 let bundle_prefix = "dev.world.benchmobile";
1654 let project_pascal = "BenchRunner";
1655
1656 let result = generate_ios_project(
1657 &temp_dir,
1658 crate_name,
1659 project_pascal,
1660 bundle_prefix,
1661 "bench_mobile::test_func",
1662 );
1663 assert!(
1664 result.is_ok(),
1665 "generate_ios_project failed: {:?}",
1666 result.err()
1667 );
1668
1669 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1671 assert!(project_yml_path.exists(), "project.yml should exist");
1672
1673 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1675
1676 assert!(
1679 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1680 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1681 project_yml
1682 );
1683 assert!(
1684 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1685 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1686 project_yml
1687 );
1688 assert!(
1689 project_yml.contains("embed: false"),
1690 "Static xcframework dependency should be link-only, got:\n{}",
1691 project_yml
1692 );
1693
1694 fs::remove_dir_all(&temp_dir).ok();
1696 }
1697
1698 #[test]
1699 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1700 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1701 let _ = fs::remove_dir_all(&temp_dir);
1702 fs::create_dir_all(&temp_dir).unwrap();
1703
1704 generate_ios_project(
1705 &temp_dir,
1706 "bench_mobile",
1707 "BenchRunner",
1708 "dev.world.benchmobile",
1709 "bench_mobile::bench_prepare",
1710 )
1711 .unwrap();
1712
1713 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1714 fs::create_dir_all(resources_dir.join("nested")).unwrap();
1715 fs::write(
1716 resources_dir.join("bench_spec.json"),
1717 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1718 )
1719 .unwrap();
1720 fs::write(
1721 resources_dir.join("bench_meta.json"),
1722 r#"{"build_id":"build-123"}"#,
1723 )
1724 .unwrap();
1725 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1726
1727 generate_ios_project(
1728 &temp_dir,
1729 "bench_mobile",
1730 "BenchRunner",
1731 "dev.world.benchmobile",
1732 "bench_mobile::bench_prepare",
1733 )
1734 .unwrap();
1735
1736 assert_eq!(
1737 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1738 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1739 );
1740 assert_eq!(
1741 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1742 r#"{"build_id":"build-123"}"#
1743 );
1744 assert_eq!(
1745 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1746 "keep me"
1747 );
1748
1749 fs::remove_dir_all(&temp_dir).ok();
1750 }
1751
1752 #[test]
1753 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1754 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1755 let _ = fs::remove_dir_all(&temp_dir);
1756 fs::create_dir_all(&temp_dir).unwrap();
1757
1758 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1759 .expect("initial iOS project generation should succeed");
1760
1761 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1762 assert!(content_view_path.exists(), "ContentView.swift should exist");
1763
1764 fs::write(&content_view_path, "stale generated content").unwrap();
1765
1766 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1767 .expect("refreshing existing iOS project should succeed");
1768
1769 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1770 assert!(
1771 refreshed.contains("ProfileLaunchOptions"),
1772 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1773 refreshed
1774 );
1775 assert!(
1776 refreshed.contains("repeatUntilMs"),
1777 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1778 refreshed
1779 );
1780 assert!(
1781 refreshed.contains("Task.detached(priority: .userInitiated)"),
1782 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
1783 refreshed
1784 );
1785 assert!(
1786 refreshed.contains("await MainActor.run"),
1787 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
1788 refreshed
1789 );
1790
1791 fs::remove_dir_all(&temp_dir).ok();
1792 }
1793
1794 #[test]
1795 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
1796 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
1797 let _ = fs::remove_dir_all(&temp_dir);
1798 fs::create_dir_all(&temp_dir).unwrap();
1799
1800 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1801 .expect("initial iOS project generation should succeed");
1802
1803 let ui_test_path =
1804 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1805 assert!(
1806 ui_test_path.exists(),
1807 "BenchRunnerUITests.swift should exist"
1808 );
1809
1810 fs::write(&ui_test_path, "stale generated content").unwrap();
1811
1812 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1813 .expect("refreshing existing iOS project should succeed");
1814
1815 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
1816 assert!(
1817 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
1818 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
1819 refreshed
1820 );
1821 assert!(
1822 refreshed.contains(
1823 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
1824 ),
1825 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
1826 refreshed
1827 );
1828
1829 fs::remove_dir_all(&temp_dir).ok();
1830 }
1831
1832 #[test]
1833 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
1834 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1835 let _ = fs::remove_dir_all(&temp_dir);
1836 fs::create_dir_all(&temp_dir).unwrap();
1837
1838 let result = generate_ios_project_with_timeout(
1839 &temp_dir,
1840 "sample_fns",
1841 "BenchRunner",
1842 "dev.world.samplefns",
1843 "sample_fns::example_benchmark",
1844 1200,
1845 );
1846
1847 assert!(result.is_ok(), "generate_ios_project should succeed");
1848
1849 let ui_test_path =
1850 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1851 let contents = fs::read_to_string(&ui_test_path).unwrap();
1852 assert!(
1853 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
1854 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
1855 contents
1856 );
1857
1858 fs::remove_dir_all(&temp_dir).ok();
1859 }
1860
1861 #[test]
1862 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
1863 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
1864 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
1865 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
1866 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
1867 }
1868
1869 #[test]
1870 fn test_cross_platform_naming_consistency() {
1871 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1873 let _ = fs::remove_dir_all(&temp_dir);
1874 fs::create_dir_all(&temp_dir).unwrap();
1875
1876 let project_name = "bench-mobile";
1877
1878 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1880 assert!(
1881 result.is_ok(),
1882 "generate_android_project failed: {:?}",
1883 result.err()
1884 );
1885
1886 let bundle_id_component = sanitize_bundle_id_component(project_name);
1888 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1889 let result = generate_ios_project(
1890 &temp_dir,
1891 &project_name.replace('-', "_"),
1892 "BenchRunner",
1893 &bundle_prefix,
1894 "bench_mobile::test_func",
1895 );
1896 assert!(
1897 result.is_ok(),
1898 "generate_ios_project failed: {:?}",
1899 result.err()
1900 );
1901
1902 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1904 .expect("Failed to read Android build.gradle");
1905
1906 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1908 .expect("Failed to read iOS project.yml");
1909
1910 assert!(
1914 android_build_gradle.contains("dev.world.benchmobile"),
1915 "Android package should be 'dev.world.benchmobile', got:\n{}",
1916 android_build_gradle
1917 );
1918 assert!(
1919 ios_project_yml.contains("dev.world.benchmobile"),
1920 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1921 ios_project_yml
1922 );
1923
1924 assert!(
1926 !android_build_gradle.contains("dev.world.bench-mobile"),
1927 "Android package should NOT contain hyphens"
1928 );
1929 assert!(
1930 !android_build_gradle.contains("dev.world.bench_mobile"),
1931 "Android package should NOT contain underscores"
1932 );
1933
1934 fs::remove_dir_all(&temp_dir).ok();
1936 }
1937
1938 #[test]
1939 fn test_cross_platform_version_consistency() {
1940 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1942 let _ = fs::remove_dir_all(&temp_dir);
1943 fs::create_dir_all(&temp_dir).unwrap();
1944
1945 let project_name = "test-project";
1946
1947 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1949 assert!(
1950 result.is_ok(),
1951 "generate_android_project failed: {:?}",
1952 result.err()
1953 );
1954
1955 let bundle_id_component = sanitize_bundle_id_component(project_name);
1957 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1958 let result = generate_ios_project(
1959 &temp_dir,
1960 &project_name.replace('-', "_"),
1961 "BenchRunner",
1962 &bundle_prefix,
1963 "test_project::test_func",
1964 );
1965 assert!(
1966 result.is_ok(),
1967 "generate_ios_project failed: {:?}",
1968 result.err()
1969 );
1970
1971 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1973 .expect("Failed to read Android build.gradle");
1974
1975 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1977 .expect("Failed to read iOS project.yml");
1978
1979 assert!(
1981 android_build_gradle.contains("versionName \"1.0.0\""),
1982 "Android versionName should be '1.0.0', got:\n{}",
1983 android_build_gradle
1984 );
1985 assert!(
1986 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1987 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1988 ios_project_yml
1989 );
1990
1991 fs::remove_dir_all(&temp_dir).ok();
1993 }
1994
1995 #[test]
1996 fn test_bundle_id_prefix_consistency() {
1997 let test_cases = vec![
1999 ("my-project", "dev.world.myproject"),
2000 ("bench_mobile", "dev.world.benchmobile"),
2001 ("TestApp", "dev.world.testapp"),
2002 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2003 (
2004 "app_with_many_underscores",
2005 "dev.world.appwithmanyunderscores",
2006 ),
2007 ];
2008
2009 for (input, expected_prefix) in test_cases {
2010 let sanitized = sanitize_bundle_id_component(input);
2011 let full_prefix = format!("dev.world.{}", sanitized);
2012 assert_eq!(
2013 full_prefix, expected_prefix,
2014 "For input '{}', expected '{}' but got '{}'",
2015 input, expected_prefix, full_prefix
2016 );
2017 }
2018 }
2019}