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 = "..", default-features = false, features = ["registry"] }}
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 && let Ok(text) = std::str::from_utf8(&contents) {
772 let rendered = render_template(text, vars);
773 validate_no_unreplaced_placeholders(&rendered, &relative)?;
775 contents = rendered.into_bytes();
776 }
777
778 let out_path = out_root.join(relative);
779 if let Some(parent) = out_path.parent() {
780 fs::create_dir_all(parent)?;
781 }
782 fs::write(&out_path, contents)?;
783 }
784 }
785 }
786 Ok(())
787}
788
789fn is_template_file(path: &Path) -> bool {
792 if let Some(ext) = path.extension() {
794 if ext == "template" {
795 return true;
796 }
797 if let Some(ext_str) = ext.to_str() {
799 return TEMPLATE_EXTENSIONS.contains(&ext_str);
800 }
801 }
802 if let Some(stem) = path.file_stem() {
804 let stem_path = Path::new(stem);
805 if let Some(ext) = stem_path.extension()
806 && let Some(ext_str) = ext.to_str()
807 {
808 return TEMPLATE_EXTENSIONS.contains(&ext_str);
809 }
810 }
811 false
812}
813
814fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
816 let mut pos = 0;
818 let mut unreplaced = Vec::new();
819
820 while let Some(start) = content[pos..].find("{{") {
821 let abs_start = pos + start;
822 if let Some(end) = content[abs_start..].find("}}") {
823 let placeholder = &content[abs_start..abs_start + end + 2];
824 let var_name = &content[abs_start + 2..abs_start + end];
826 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
829 unreplaced.push(placeholder.to_string());
830 }
831 pos = abs_start + end + 2;
832 } else {
833 break;
834 }
835 }
836
837 if !unreplaced.is_empty() {
838 return Err(BenchError::Build(format!(
839 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
840 This is a bug in mobench-sdk. Please report it at:\n\
841 https://github.com/worldcoin/mobile-bench-rs/issues",
842 file_path, unreplaced
843 )));
844 }
845
846 Ok(())
847}
848
849fn render_template(input: &str, vars: &[TemplateVar]) -> String {
850 let mut output = input.to_string();
851 for var in vars {
852 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
853 }
854 output
855}
856
857pub fn sanitize_bundle_id_component(name: &str) -> String {
868 name.chars()
869 .filter(|c| c.is_ascii_alphanumeric())
870 .collect::<String>()
871 .to_lowercase()
872}
873
874fn sanitize_package_name(name: &str) -> String {
875 name.chars()
876 .map(|c| {
877 if c.is_ascii_alphanumeric() {
878 c.to_ascii_lowercase()
879 } else {
880 '-'
881 }
882 })
883 .collect::<String>()
884 .trim_matches('-')
885 .replace("--", "-")
886}
887
888pub fn to_pascal_case(input: &str) -> String {
890 input
891 .split(|c: char| !c.is_ascii_alphanumeric())
892 .filter(|s| !s.is_empty())
893 .map(|s| {
894 let mut chars = s.chars();
895 let first = chars.next().unwrap().to_ascii_uppercase();
896 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
897 format!("{}{}", first, rest)
898 })
899 .collect::<String>()
900}
901
902pub fn android_project_exists(output_dir: &Path) -> bool {
906 let android_dir = output_dir.join("android");
907 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
908}
909
910pub fn ios_project_exists(output_dir: &Path) -> bool {
914 output_dir.join("ios/BenchRunner/project.yml").exists()
915}
916
917fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
922 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
923 let Ok(content) = std::fs::read_to_string(&project_yml) else {
924 return false;
925 };
926 let expected = format!("../{}.xcframework", library_name);
927 content.contains(&expected)
928}
929
930fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
935 let build_gradle = output_dir.join("android/app/build.gradle");
936 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
937 return false;
938 };
939 let expected = format!("lib{}.so", library_name);
940 content.contains(&expected)
941}
942
943pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
958 let lib_rs = crate_dir.join("src/lib.rs");
959 if !lib_rs.exists() {
960 return None;
961 }
962
963 let file = fs::File::open(&lib_rs).ok()?;
964 let reader = BufReader::new(file);
965
966 let mut found_benchmark_attr = false;
967 let crate_name_normalized = crate_name.replace('-', "_");
968
969 for line in reader.lines().map_while(Result::ok) {
970 let trimmed = line.trim();
971
972 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
974 found_benchmark_attr = true;
975 continue;
976 }
977
978 if found_benchmark_attr {
980 if let Some(fn_pos) = trimmed.find("fn ") {
982 let after_fn = &trimmed[fn_pos + 3..];
983 let fn_name: String = after_fn
985 .chars()
986 .take_while(|c| c.is_alphanumeric() || *c == '_')
987 .collect();
988
989 if !fn_name.is_empty() {
990 return Some(format!("{}::{}", crate_name_normalized, fn_name));
991 }
992 }
993 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
996 found_benchmark_attr = false;
997 }
998 }
999 }
1000
1001 None
1002}
1003
1004pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1018 let lib_rs = crate_dir.join("src/lib.rs");
1019 if !lib_rs.exists() {
1020 return Vec::new();
1021 }
1022
1023 let Ok(file) = fs::File::open(&lib_rs) else {
1024 return Vec::new();
1025 };
1026 let reader = BufReader::new(file);
1027
1028 let mut benchmarks = Vec::new();
1029 let mut found_benchmark_attr = false;
1030 let crate_name_normalized = crate_name.replace('-', "_");
1031
1032 for line in reader.lines().map_while(Result::ok) {
1033 let trimmed = line.trim();
1034
1035 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1037 found_benchmark_attr = true;
1038 continue;
1039 }
1040
1041 if found_benchmark_attr {
1043 if let Some(fn_pos) = trimmed.find("fn ") {
1045 let after_fn = &trimmed[fn_pos + 3..];
1046 let fn_name: String = after_fn
1048 .chars()
1049 .take_while(|c| c.is_alphanumeric() || *c == '_')
1050 .collect();
1051
1052 if !fn_name.is_empty() {
1053 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1054 }
1055 found_benchmark_attr = false;
1056 }
1057 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1060 found_benchmark_attr = false;
1061 }
1062 }
1063 }
1064
1065 benchmarks
1066}
1067
1068pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1080 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1081 let crate_name_normalized = crate_name.replace('-', "_");
1082
1083 let normalized_name = if function_name.contains("::") {
1085 function_name.to_string()
1086 } else {
1087 format!("{}::{}", crate_name_normalized, function_name)
1088 };
1089
1090 benchmarks.iter().any(|b| b == &normalized_name)
1091}
1092
1093pub fn resolve_default_function(
1108 project_root: &Path,
1109 crate_name: &str,
1110 crate_dir: Option<&Path>,
1111) -> String {
1112 let crate_name_normalized = crate_name.replace('-', "_");
1113
1114 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1116 vec![dir.to_path_buf()]
1117 } else {
1118 vec![
1119 project_root.join("bench-mobile"),
1120 project_root.join("crates").join(crate_name),
1121 project_root.to_path_buf(),
1122 ]
1123 };
1124
1125 for dir in &search_dirs {
1127 if dir.join("Cargo.toml").exists()
1128 && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1129 {
1130 return detected;
1131 }
1132 }
1133
1134 format!("{}::example_benchmark", crate_name_normalized)
1136}
1137
1138pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1149 ensure_android_project_with_options(output_dir, crate_name, None, None)
1150}
1151
1152pub fn ensure_android_project_with_options(
1164 output_dir: &Path,
1165 crate_name: &str,
1166 project_root: Option<&Path>,
1167 crate_dir: Option<&Path>,
1168) -> Result<(), BenchError> {
1169 let library_name = crate_name.replace('-', "_");
1170 if android_project_exists(output_dir)
1171 && android_project_matches_library(output_dir, &library_name)
1172 {
1173 return Ok(());
1174 }
1175
1176 println!("Android project not found, generating scaffolding...");
1177 let project_slug = crate_name.replace('-', "_");
1178
1179 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1181 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1182
1183 generate_android_project(output_dir, &project_slug, &default_function)?;
1184 println!(
1185 " Generated Android project at {:?}",
1186 output_dir.join("android")
1187 );
1188 println!(" Default benchmark function: {}", default_function);
1189 Ok(())
1190}
1191
1192pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1203 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1204}
1205
1206pub fn ensure_ios_project_with_options(
1218 output_dir: &Path,
1219 crate_name: &str,
1220 project_root: Option<&Path>,
1221 crate_dir: Option<&Path>,
1222) -> Result<(), BenchError> {
1223 let library_name = crate_name.replace('-', "_");
1224 let project_exists = ios_project_exists(output_dir);
1225 let project_matches = ios_project_matches_library(output_dir, &library_name);
1226 if project_exists && !project_matches {
1227 println!("Existing iOS scaffolding does not match library, regenerating...");
1228 } else if project_exists {
1229 println!("Refreshing generated iOS scaffolding...");
1230 } else {
1231 println!("iOS project not found, generating scaffolding...");
1232 }
1233
1234 let project_pascal = "BenchRunner";
1236 let library_name = crate_name.replace('-', "_");
1238 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1241 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1242
1243 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1245 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1246
1247 generate_ios_project(
1248 output_dir,
1249 &library_name,
1250 project_pascal,
1251 &bundle_prefix,
1252 &default_function,
1253 )?;
1254 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1255 println!(" Default benchmark function: {}", default_function);
1256 Ok(())
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262 use std::env;
1263
1264 #[test]
1265 fn test_generate_bench_mobile_crate() {
1266 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1267 fs::create_dir_all(&temp_dir).unwrap();
1268
1269 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1270 assert!(result.is_ok());
1271
1272 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1274 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1275 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1276 let cargo_toml =
1277 fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
1278 assert!(
1279 cargo_toml.contains(
1280 r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
1281 ),
1282 "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
1283 );
1284
1285 fs::remove_dir_all(&temp_dir).ok();
1287 }
1288
1289 #[test]
1290 fn test_generate_android_project_no_unreplaced_placeholders() {
1291 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1292 let _ = fs::remove_dir_all(&temp_dir);
1294 fs::create_dir_all(&temp_dir).unwrap();
1295
1296 let result =
1297 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1298 assert!(
1299 result.is_ok(),
1300 "generate_android_project failed: {:?}",
1301 result.err()
1302 );
1303
1304 let android_dir = temp_dir.join("android");
1306 assert!(android_dir.join("settings.gradle").exists());
1307 assert!(android_dir.join("app/build.gradle").exists());
1308 assert!(
1309 android_dir
1310 .join("app/src/main/AndroidManifest.xml")
1311 .exists()
1312 );
1313 assert!(
1314 android_dir
1315 .join("app/src/main/res/values/strings.xml")
1316 .exists()
1317 );
1318 assert!(
1319 android_dir
1320 .join("app/src/main/res/values/themes.xml")
1321 .exists()
1322 );
1323
1324 let files_to_check = [
1326 "settings.gradle",
1327 "app/build.gradle",
1328 "app/src/main/AndroidManifest.xml",
1329 "app/src/main/res/values/strings.xml",
1330 "app/src/main/res/values/themes.xml",
1331 ];
1332
1333 for file in files_to_check {
1334 let path = android_dir.join(file);
1335 let contents =
1336 fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1337
1338 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1340 assert!(
1341 !has_placeholder,
1342 "File {} contains unreplaced template placeholders: {}",
1343 file, contents
1344 );
1345 }
1346
1347 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1349 assert!(
1350 settings.contains("my-bench-project-android")
1351 || settings.contains("my_bench_project-android"),
1352 "settings.gradle should contain project name"
1353 );
1354
1355 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1356 assert!(
1358 build_gradle.contains("dev.world.mybenchproject"),
1359 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1360 );
1361 assert!(
1362 !build_gradle.contains("testBuildType \"release\""),
1363 "debug builds should be able to produce assembleDebugAndroidTest"
1364 );
1365 assert!(
1366 build_gradle.contains("mobenchTestBuildType"),
1367 "release builds should be able to request assembleReleaseAndroidTest"
1368 );
1369
1370 let manifest =
1371 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1372 assert!(
1373 manifest.contains("Theme.MyBenchProject"),
1374 "AndroidManifest.xml should contain PascalCase theme name"
1375 );
1376
1377 let strings =
1378 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1379 assert!(
1380 strings.contains("Benchmark"),
1381 "strings.xml should contain app name with Benchmark"
1382 );
1383
1384 let main_activity_path =
1387 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1388 assert!(
1389 main_activity_path.exists(),
1390 "MainActivity.kt should be in package directory: {:?}",
1391 main_activity_path
1392 );
1393
1394 let test_activity_path = android_dir
1395 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1396 assert!(
1397 test_activity_path.exists(),
1398 "MainActivityTest.kt should be in package directory: {:?}",
1399 test_activity_path
1400 );
1401
1402 assert!(
1404 !android_dir
1405 .join("app/src/main/java/MainActivity.kt")
1406 .exists(),
1407 "MainActivity.kt should not be in root java directory"
1408 );
1409 assert!(
1410 !android_dir
1411 .join("app/src/androidTest/java/MainActivityTest.kt")
1412 .exists(),
1413 "MainActivityTest.kt should not be in root java directory"
1414 );
1415
1416 fs::remove_dir_all(&temp_dir).ok();
1418 }
1419
1420 #[test]
1421 fn test_generate_android_project_replaces_previous_package_tree() {
1422 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1423 let _ = fs::remove_dir_all(&temp_dir);
1424 fs::create_dir_all(&temp_dir).unwrap();
1425
1426 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1427 .unwrap();
1428 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1429 assert!(
1430 old_package_dir.exists(),
1431 "expected first package tree to exist"
1432 );
1433
1434 generate_android_project(
1435 &temp_dir,
1436 "basic_benchmark",
1437 "basic_benchmark::bench_fibonacci",
1438 )
1439 .unwrap();
1440
1441 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1442 assert!(
1443 new_package_dir.exists(),
1444 "expected new package tree to exist"
1445 );
1446 assert!(
1447 !old_package_dir.exists(),
1448 "old package tree should be removed when regenerating the Android scaffold"
1449 );
1450
1451 fs::remove_dir_all(&temp_dir).ok();
1452 }
1453
1454 #[test]
1455 fn test_is_template_file() {
1456 assert!(is_template_file(Path::new("settings.gradle")));
1457 assert!(is_template_file(Path::new("app/build.gradle")));
1458 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1459 assert!(is_template_file(Path::new("strings.xml")));
1460 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1461 assert!(is_template_file(Path::new("project.yml")));
1462 assert!(is_template_file(Path::new("Info.plist")));
1463 assert!(!is_template_file(Path::new("libfoo.so")));
1464 assert!(!is_template_file(Path::new("image.png")));
1465 }
1466
1467 #[test]
1468 fn test_mobile_templates_read_process_peak_memory_compatibly() {
1469 let android =
1470 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1471 assert!(
1472 !android.contains("sample.processPeakMemoryKb"),
1473 "Android template should not require generated bindings to expose processPeakMemoryKb"
1474 );
1475 assert!(
1476 !android.contains("it.processPeakMemoryKb"),
1477 "Android template should not require generated bindings to expose processPeakMemoryKb"
1478 );
1479 assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1480 assert!(
1481 !android.contains("sample.cpuTimeMs"),
1482 "Android template should tolerate BenchSample without cpuTimeMs"
1483 );
1484 assert!(
1485 !android.contains("sample.peakMemoryKb"),
1486 "Android template should tolerate BenchSample without peakMemoryKb"
1487 );
1488 assert!(
1489 !android.contains("report.phases"),
1490 "Android template should tolerate BenchReport without phases"
1491 );
1492 assert!(android.contains("ProcessMemorySampler"));
1493 assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1494 assert!(android.contains("/proc/self/smaps_rollup"));
1495 assert!(android.contains("class BenchmarkWorkerService : Service()"));
1496 assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1497 assert!(android.contains("startForegroundService(intent)"));
1498 assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1499 assert!(android.contains("fun isBenchmarkComplete()"));
1500 assert!(!android.contains("resultLatch.await"));
1501 assert!(android.contains("memory_process\", \"isolated_worker\""));
1502
1503 let android_test = include_str!(
1504 "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1505 );
1506 assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1507 assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1508 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1509 assert!(android_test.contains("activity.isBenchmarkComplete()"));
1510
1511 let ios_test = include_str!(
1512 "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1513 );
1514 assert!(
1515 ios_test.contains("\\\"error\\\""),
1516 "iOS XCUITest template should fail when the benchmark report is an error payload"
1517 );
1518
1519 let android_manifest =
1520 include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1521 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1522 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1523 assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1524 assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1525 assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1526
1527 let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1528 assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1529 assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1530
1531 let ios =
1532 include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1533 assert!(
1534 !ios.contains("sample.processPeakMemoryKb"),
1535 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1536 );
1537 assert!(
1538 !ios.contains(r"\.processPeakMemoryKb"),
1539 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1540 );
1541 assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1542 assert!(ios.contains("return [\n \"name\": name,"));
1543 assert!(
1544 !ios.contains("sample.cpuTimeMs"),
1545 "iOS template should tolerate BenchSample without cpuTimeMs"
1546 );
1547 assert!(
1548 !ios.contains("sample.peakMemoryKb"),
1549 "iOS template should tolerate BenchSample without peakMemoryKb"
1550 );
1551 assert!(
1552 !ios.contains("report.phases"),
1553 "iOS template should tolerate BenchReport without phases"
1554 );
1555 assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1556 assert!(ios.contains("ProcessMemorySampler"));
1557 assert!(ios.contains("currentProcessResidentMemoryKb"));
1558 assert!(ios.contains("task_info("));
1559 assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1560 assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1561 assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1562 }
1563
1564 #[test]
1565 fn test_validate_no_unreplaced_placeholders() {
1566 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1568
1569 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1571
1572 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1574 assert!(result.is_err());
1575 let err = result.unwrap_err().to_string();
1576 assert!(err.contains("{{NAME}}"));
1577 }
1578
1579 #[test]
1580 fn test_to_pascal_case() {
1581 assert_eq!(to_pascal_case("my-project"), "MyProject");
1582 assert_eq!(to_pascal_case("my_project"), "MyProject");
1583 assert_eq!(to_pascal_case("myproject"), "Myproject");
1584 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1585 }
1586
1587 #[test]
1588 fn test_detect_default_function_finds_benchmark() {
1589 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1590 let _ = fs::remove_dir_all(&temp_dir);
1591 fs::create_dir_all(temp_dir.join("src")).unwrap();
1592
1593 let lib_content = r#"
1595use mobench_sdk::benchmark;
1596
1597/// Some docs
1598#[benchmark]
1599fn my_benchmark_func() {
1600 // benchmark code
1601}
1602
1603fn helper_func() {}
1604"#;
1605 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1606 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1607
1608 let result = detect_default_function(&temp_dir, "my_crate");
1609 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1610
1611 fs::remove_dir_all(&temp_dir).ok();
1613 }
1614
1615 #[test]
1616 fn test_detect_default_function_no_benchmark() {
1617 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1618 let _ = fs::remove_dir_all(&temp_dir);
1619 fs::create_dir_all(temp_dir.join("src")).unwrap();
1620
1621 let lib_content = r#"
1623fn regular_function() {
1624 // no benchmark here
1625}
1626"#;
1627 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1628
1629 let result = detect_default_function(&temp_dir, "my_crate");
1630 assert!(result.is_none());
1631
1632 fs::remove_dir_all(&temp_dir).ok();
1634 }
1635
1636 #[test]
1637 fn test_detect_default_function_pub_fn() {
1638 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1639 let _ = fs::remove_dir_all(&temp_dir);
1640 fs::create_dir_all(temp_dir.join("src")).unwrap();
1641
1642 let lib_content = r#"
1644#[benchmark]
1645pub fn public_bench() {
1646 // benchmark code
1647}
1648"#;
1649 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1650
1651 let result = detect_default_function(&temp_dir, "test-crate");
1652 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1653
1654 fs::remove_dir_all(&temp_dir).ok();
1656 }
1657
1658 #[test]
1659 fn test_resolve_default_function_fallback() {
1660 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1661 let _ = fs::remove_dir_all(&temp_dir);
1662 fs::create_dir_all(&temp_dir).unwrap();
1663
1664 let result = resolve_default_function(&temp_dir, "my-crate", None);
1666 assert_eq!(result, "my_crate::example_benchmark");
1667
1668 fs::remove_dir_all(&temp_dir).ok();
1670 }
1671
1672 #[test]
1673 fn test_sanitize_bundle_id_component() {
1674 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1676 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1678 assert_eq!(
1680 sanitize_bundle_id_component("my-project_name"),
1681 "myprojectname"
1682 );
1683 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1685 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1687 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1689 assert_eq!(
1691 sanitize_bundle_id_component("My-Complex_Project-123"),
1692 "mycomplexproject123"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_generate_ios_project_bundle_id_not_duplicated() {
1698 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1699 let _ = fs::remove_dir_all(&temp_dir);
1701 fs::create_dir_all(&temp_dir).unwrap();
1702
1703 let crate_name = "bench-mobile";
1705 let bundle_prefix = "dev.world.benchmobile";
1706 let project_pascal = "BenchRunner";
1707
1708 let result = generate_ios_project(
1709 &temp_dir,
1710 crate_name,
1711 project_pascal,
1712 bundle_prefix,
1713 "bench_mobile::test_func",
1714 );
1715 assert!(
1716 result.is_ok(),
1717 "generate_ios_project failed: {:?}",
1718 result.err()
1719 );
1720
1721 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1723 assert!(project_yml_path.exists(), "project.yml should exist");
1724
1725 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1727
1728 assert!(
1731 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1732 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1733 project_yml
1734 );
1735 assert!(
1736 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1737 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1738 project_yml
1739 );
1740 assert!(
1741 project_yml.contains("embed: false"),
1742 "Static xcframework dependency should be link-only, got:\n{}",
1743 project_yml
1744 );
1745
1746 fs::remove_dir_all(&temp_dir).ok();
1748 }
1749
1750 #[test]
1751 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1752 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1753 let _ = fs::remove_dir_all(&temp_dir);
1754 fs::create_dir_all(&temp_dir).unwrap();
1755
1756 generate_ios_project(
1757 &temp_dir,
1758 "bench_mobile",
1759 "BenchRunner",
1760 "dev.world.benchmobile",
1761 "bench_mobile::bench_prepare",
1762 )
1763 .unwrap();
1764
1765 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1766 fs::create_dir_all(resources_dir.join("nested")).unwrap();
1767 fs::write(
1768 resources_dir.join("bench_spec.json"),
1769 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1770 )
1771 .unwrap();
1772 fs::write(
1773 resources_dir.join("bench_meta.json"),
1774 r#"{"build_id":"build-123"}"#,
1775 )
1776 .unwrap();
1777 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1778
1779 generate_ios_project(
1780 &temp_dir,
1781 "bench_mobile",
1782 "BenchRunner",
1783 "dev.world.benchmobile",
1784 "bench_mobile::bench_prepare",
1785 )
1786 .unwrap();
1787
1788 assert_eq!(
1789 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1790 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1791 );
1792 assert_eq!(
1793 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1794 r#"{"build_id":"build-123"}"#
1795 );
1796 assert_eq!(
1797 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1798 "keep me"
1799 );
1800
1801 fs::remove_dir_all(&temp_dir).ok();
1802 }
1803
1804 #[test]
1805 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1806 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1807 let _ = fs::remove_dir_all(&temp_dir);
1808 fs::create_dir_all(&temp_dir).unwrap();
1809
1810 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1811 .expect("initial iOS project generation should succeed");
1812
1813 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1814 assert!(content_view_path.exists(), "ContentView.swift should exist");
1815
1816 fs::write(&content_view_path, "stale generated content").unwrap();
1817
1818 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1819 .expect("refreshing existing iOS project should succeed");
1820
1821 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1822 assert!(
1823 refreshed.contains("ProfileLaunchOptions"),
1824 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1825 refreshed
1826 );
1827 assert!(
1828 refreshed.contains("repeatUntilMs"),
1829 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1830 refreshed
1831 );
1832 assert!(
1833 refreshed.contains("Task.detached(priority: .userInitiated)"),
1834 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
1835 refreshed
1836 );
1837 assert!(
1838 refreshed.contains("await MainActor.run"),
1839 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
1840 refreshed
1841 );
1842
1843 fs::remove_dir_all(&temp_dir).ok();
1844 }
1845
1846 #[test]
1847 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
1848 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
1849 let _ = fs::remove_dir_all(&temp_dir);
1850 fs::create_dir_all(&temp_dir).unwrap();
1851
1852 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1853 .expect("initial iOS project generation should succeed");
1854
1855 let ui_test_path =
1856 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1857 assert!(
1858 ui_test_path.exists(),
1859 "BenchRunnerUITests.swift should exist"
1860 );
1861
1862 fs::write(&ui_test_path, "stale generated content").unwrap();
1863
1864 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1865 .expect("refreshing existing iOS project should succeed");
1866
1867 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
1868 assert!(
1869 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
1870 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
1871 refreshed
1872 );
1873 assert!(
1874 refreshed.contains(
1875 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
1876 ),
1877 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
1878 refreshed
1879 );
1880
1881 fs::remove_dir_all(&temp_dir).ok();
1882 }
1883
1884 #[test]
1885 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
1886 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1887 let _ = fs::remove_dir_all(&temp_dir);
1888 fs::create_dir_all(&temp_dir).unwrap();
1889
1890 let result = generate_ios_project_with_timeout(
1891 &temp_dir,
1892 "sample_fns",
1893 "BenchRunner",
1894 "dev.world.samplefns",
1895 "sample_fns::example_benchmark",
1896 1200,
1897 );
1898
1899 assert!(result.is_ok(), "generate_ios_project should succeed");
1900
1901 let ui_test_path =
1902 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1903 let contents = fs::read_to_string(&ui_test_path).unwrap();
1904 assert!(
1905 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
1906 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
1907 contents
1908 );
1909
1910 fs::remove_dir_all(&temp_dir).ok();
1911 }
1912
1913 #[test]
1914 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
1915 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
1916 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
1917 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
1918 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
1919 }
1920
1921 #[test]
1922 fn test_cross_platform_naming_consistency() {
1923 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1925 let _ = fs::remove_dir_all(&temp_dir);
1926 fs::create_dir_all(&temp_dir).unwrap();
1927
1928 let project_name = "bench-mobile";
1929
1930 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1932 assert!(
1933 result.is_ok(),
1934 "generate_android_project failed: {:?}",
1935 result.err()
1936 );
1937
1938 let bundle_id_component = sanitize_bundle_id_component(project_name);
1940 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1941 let result = generate_ios_project(
1942 &temp_dir,
1943 &project_name.replace('-', "_"),
1944 "BenchRunner",
1945 &bundle_prefix,
1946 "bench_mobile::test_func",
1947 );
1948 assert!(
1949 result.is_ok(),
1950 "generate_ios_project failed: {:?}",
1951 result.err()
1952 );
1953
1954 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1956 .expect("Failed to read Android build.gradle");
1957
1958 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1960 .expect("Failed to read iOS project.yml");
1961
1962 assert!(
1966 android_build_gradle.contains("dev.world.benchmobile"),
1967 "Android package should be 'dev.world.benchmobile', got:\n{}",
1968 android_build_gradle
1969 );
1970 assert!(
1971 ios_project_yml.contains("dev.world.benchmobile"),
1972 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1973 ios_project_yml
1974 );
1975
1976 assert!(
1978 !android_build_gradle.contains("dev.world.bench-mobile"),
1979 "Android package should NOT contain hyphens"
1980 );
1981 assert!(
1982 !android_build_gradle.contains("dev.world.bench_mobile"),
1983 "Android package should NOT contain underscores"
1984 );
1985
1986 fs::remove_dir_all(&temp_dir).ok();
1988 }
1989
1990 #[test]
1991 fn test_cross_platform_version_consistency() {
1992 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1994 let _ = fs::remove_dir_all(&temp_dir);
1995 fs::create_dir_all(&temp_dir).unwrap();
1996
1997 let project_name = "test-project";
1998
1999 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
2001 assert!(
2002 result.is_ok(),
2003 "generate_android_project failed: {:?}",
2004 result.err()
2005 );
2006
2007 let bundle_id_component = sanitize_bundle_id_component(project_name);
2009 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2010 let result = generate_ios_project(
2011 &temp_dir,
2012 &project_name.replace('-', "_"),
2013 "BenchRunner",
2014 &bundle_prefix,
2015 "test_project::test_func",
2016 );
2017 assert!(
2018 result.is_ok(),
2019 "generate_ios_project failed: {:?}",
2020 result.err()
2021 );
2022
2023 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2025 .expect("Failed to read Android build.gradle");
2026
2027 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2029 .expect("Failed to read iOS project.yml");
2030
2031 assert!(
2033 android_build_gradle.contains("versionName \"1.0.0\""),
2034 "Android versionName should be '1.0.0', got:\n{}",
2035 android_build_gradle
2036 );
2037 assert!(
2038 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2039 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2040 ios_project_yml
2041 );
2042
2043 fs::remove_dir_all(&temp_dir).ok();
2045 }
2046
2047 #[test]
2048 fn test_bundle_id_prefix_consistency() {
2049 let test_cases = vec![
2051 ("my-project", "dev.world.myproject"),
2052 ("bench_mobile", "dev.world.benchmobile"),
2053 ("TestApp", "dev.world.testapp"),
2054 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2055 (
2056 "app_with_many_underscores",
2057 "dev.world.appwithmanyunderscores",
2058 ),
2059 ];
2060
2061 for (input, expected_prefix) in test_cases {
2062 let sanitized = sanitize_bundle_id_component(input);
2063 let full_prefix = format!("dev.world.{}", sanitized);
2064 assert_eq!(
2065 full_prefix, expected_prefix,
2066 "For input '{}', expected '{}' but got '{}'",
2067 input, expected_prefix, full_prefix
2068 );
2069 }
2070 }
2071}