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 && 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
1277 fs::remove_dir_all(&temp_dir).ok();
1279 }
1280
1281 #[test]
1282 fn test_generate_android_project_no_unreplaced_placeholders() {
1283 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1284 let _ = fs::remove_dir_all(&temp_dir);
1286 fs::create_dir_all(&temp_dir).unwrap();
1287
1288 let result =
1289 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1290 assert!(
1291 result.is_ok(),
1292 "generate_android_project failed: {:?}",
1293 result.err()
1294 );
1295
1296 let android_dir = temp_dir.join("android");
1298 assert!(android_dir.join("settings.gradle").exists());
1299 assert!(android_dir.join("app/build.gradle").exists());
1300 assert!(
1301 android_dir
1302 .join("app/src/main/AndroidManifest.xml")
1303 .exists()
1304 );
1305 assert!(
1306 android_dir
1307 .join("app/src/main/res/values/strings.xml")
1308 .exists()
1309 );
1310 assert!(
1311 android_dir
1312 .join("app/src/main/res/values/themes.xml")
1313 .exists()
1314 );
1315
1316 let files_to_check = [
1318 "settings.gradle",
1319 "app/build.gradle",
1320 "app/src/main/AndroidManifest.xml",
1321 "app/src/main/res/values/strings.xml",
1322 "app/src/main/res/values/themes.xml",
1323 ];
1324
1325 for file in files_to_check {
1326 let path = android_dir.join(file);
1327 let contents =
1328 fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1329
1330 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1332 assert!(
1333 !has_placeholder,
1334 "File {} contains unreplaced template placeholders: {}",
1335 file, contents
1336 );
1337 }
1338
1339 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1341 assert!(
1342 settings.contains("my-bench-project-android")
1343 || settings.contains("my_bench_project-android"),
1344 "settings.gradle should contain project name"
1345 );
1346
1347 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1348 assert!(
1350 build_gradle.contains("dev.world.mybenchproject"),
1351 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1352 );
1353 assert!(
1354 !build_gradle.contains("testBuildType \"release\""),
1355 "debug builds should be able to produce assembleDebugAndroidTest"
1356 );
1357 assert!(
1358 build_gradle.contains("mobenchTestBuildType"),
1359 "release builds should be able to request assembleReleaseAndroidTest"
1360 );
1361
1362 let manifest =
1363 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1364 assert!(
1365 manifest.contains("Theme.MyBenchProject"),
1366 "AndroidManifest.xml should contain PascalCase theme name"
1367 );
1368
1369 let strings =
1370 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1371 assert!(
1372 strings.contains("Benchmark"),
1373 "strings.xml should contain app name with Benchmark"
1374 );
1375
1376 let main_activity_path =
1379 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1380 assert!(
1381 main_activity_path.exists(),
1382 "MainActivity.kt should be in package directory: {:?}",
1383 main_activity_path
1384 );
1385
1386 let test_activity_path = android_dir
1387 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1388 assert!(
1389 test_activity_path.exists(),
1390 "MainActivityTest.kt should be in package directory: {:?}",
1391 test_activity_path
1392 );
1393
1394 assert!(
1396 !android_dir
1397 .join("app/src/main/java/MainActivity.kt")
1398 .exists(),
1399 "MainActivity.kt should not be in root java directory"
1400 );
1401 assert!(
1402 !android_dir
1403 .join("app/src/androidTest/java/MainActivityTest.kt")
1404 .exists(),
1405 "MainActivityTest.kt should not be in root java directory"
1406 );
1407
1408 fs::remove_dir_all(&temp_dir).ok();
1410 }
1411
1412 #[test]
1413 fn test_generate_android_project_replaces_previous_package_tree() {
1414 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1415 let _ = fs::remove_dir_all(&temp_dir);
1416 fs::create_dir_all(&temp_dir).unwrap();
1417
1418 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1419 .unwrap();
1420 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1421 assert!(
1422 old_package_dir.exists(),
1423 "expected first package tree to exist"
1424 );
1425
1426 generate_android_project(
1427 &temp_dir,
1428 "basic_benchmark",
1429 "basic_benchmark::bench_fibonacci",
1430 )
1431 .unwrap();
1432
1433 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1434 assert!(
1435 new_package_dir.exists(),
1436 "expected new package tree to exist"
1437 );
1438 assert!(
1439 !old_package_dir.exists(),
1440 "old package tree should be removed when regenerating the Android scaffold"
1441 );
1442
1443 fs::remove_dir_all(&temp_dir).ok();
1444 }
1445
1446 #[test]
1447 fn test_is_template_file() {
1448 assert!(is_template_file(Path::new("settings.gradle")));
1449 assert!(is_template_file(Path::new("app/build.gradle")));
1450 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1451 assert!(is_template_file(Path::new("strings.xml")));
1452 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1453 assert!(is_template_file(Path::new("project.yml")));
1454 assert!(is_template_file(Path::new("Info.plist")));
1455 assert!(!is_template_file(Path::new("libfoo.so")));
1456 assert!(!is_template_file(Path::new("image.png")));
1457 }
1458
1459 #[test]
1460 fn test_mobile_templates_read_process_peak_memory_compatibly() {
1461 let android =
1462 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1463 assert!(
1464 !android.contains("sample.processPeakMemoryKb"),
1465 "Android template should not require generated bindings to expose processPeakMemoryKb"
1466 );
1467 assert!(
1468 !android.contains("it.processPeakMemoryKb"),
1469 "Android template should not require generated bindings to expose processPeakMemoryKb"
1470 );
1471 assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1472 assert!(
1473 !android.contains("sample.cpuTimeMs"),
1474 "Android template should tolerate BenchSample without cpuTimeMs"
1475 );
1476 assert!(
1477 !android.contains("sample.peakMemoryKb"),
1478 "Android template should tolerate BenchSample without peakMemoryKb"
1479 );
1480 assert!(
1481 !android.contains("report.phases"),
1482 "Android template should tolerate BenchReport without phases"
1483 );
1484 assert!(android.contains("ProcessMemorySampler"));
1485 assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1486 assert!(android.contains("/proc/self/smaps_rollup"));
1487 assert!(android.contains("class BenchmarkWorkerService : Service()"));
1488 assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1489 assert!(android.contains("startForegroundService(intent)"));
1490 assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1491 assert!(android.contains("fun isBenchmarkComplete()"));
1492 assert!(!android.contains("resultLatch.await"));
1493 assert!(android.contains("memory_process\", \"isolated_worker\""));
1494
1495 let android_test = include_str!(
1496 "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1497 );
1498 assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1499 assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1500 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1501 assert!(android_test.contains("activity.isBenchmarkComplete()"));
1502
1503 let ios_test = include_str!(
1504 "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1505 );
1506 assert!(
1507 ios_test.contains("\\\"error\\\""),
1508 "iOS XCUITest template should fail when the benchmark report is an error payload"
1509 );
1510
1511 let android_manifest =
1512 include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1513 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1514 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1515 assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1516 assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1517 assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1518
1519 let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1520 assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1521 assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1522
1523 let ios =
1524 include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1525 assert!(
1526 !ios.contains("sample.processPeakMemoryKb"),
1527 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1528 );
1529 assert!(
1530 !ios.contains(r"\.processPeakMemoryKb"),
1531 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1532 );
1533 assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1534 assert!(ios.contains("return [\n \"name\": name,"));
1535 assert!(
1536 !ios.contains("sample.cpuTimeMs"),
1537 "iOS template should tolerate BenchSample without cpuTimeMs"
1538 );
1539 assert!(
1540 !ios.contains("sample.peakMemoryKb"),
1541 "iOS template should tolerate BenchSample without peakMemoryKb"
1542 );
1543 assert!(
1544 !ios.contains("report.phases"),
1545 "iOS template should tolerate BenchReport without phases"
1546 );
1547 assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1548 assert!(ios.contains("ProcessMemorySampler"));
1549 assert!(ios.contains("currentProcessResidentMemoryKb"));
1550 assert!(ios.contains("task_info("));
1551 assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1552 assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1553 assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1554 }
1555
1556 #[test]
1557 fn test_validate_no_unreplaced_placeholders() {
1558 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1560
1561 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1563
1564 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1566 assert!(result.is_err());
1567 let err = result.unwrap_err().to_string();
1568 assert!(err.contains("{{NAME}}"));
1569 }
1570
1571 #[test]
1572 fn test_to_pascal_case() {
1573 assert_eq!(to_pascal_case("my-project"), "MyProject");
1574 assert_eq!(to_pascal_case("my_project"), "MyProject");
1575 assert_eq!(to_pascal_case("myproject"), "Myproject");
1576 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1577 }
1578
1579 #[test]
1580 fn test_detect_default_function_finds_benchmark() {
1581 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1582 let _ = fs::remove_dir_all(&temp_dir);
1583 fs::create_dir_all(temp_dir.join("src")).unwrap();
1584
1585 let lib_content = r#"
1587use mobench_sdk::benchmark;
1588
1589/// Some docs
1590#[benchmark]
1591fn my_benchmark_func() {
1592 // benchmark code
1593}
1594
1595fn helper_func() {}
1596"#;
1597 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1598 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1599
1600 let result = detect_default_function(&temp_dir, "my_crate");
1601 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1602
1603 fs::remove_dir_all(&temp_dir).ok();
1605 }
1606
1607 #[test]
1608 fn test_detect_default_function_no_benchmark() {
1609 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1610 let _ = fs::remove_dir_all(&temp_dir);
1611 fs::create_dir_all(temp_dir.join("src")).unwrap();
1612
1613 let lib_content = r#"
1615fn regular_function() {
1616 // no benchmark here
1617}
1618"#;
1619 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1620
1621 let result = detect_default_function(&temp_dir, "my_crate");
1622 assert!(result.is_none());
1623
1624 fs::remove_dir_all(&temp_dir).ok();
1626 }
1627
1628 #[test]
1629 fn test_detect_default_function_pub_fn() {
1630 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1631 let _ = fs::remove_dir_all(&temp_dir);
1632 fs::create_dir_all(temp_dir.join("src")).unwrap();
1633
1634 let lib_content = r#"
1636#[benchmark]
1637pub fn public_bench() {
1638 // benchmark code
1639}
1640"#;
1641 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1642
1643 let result = detect_default_function(&temp_dir, "test-crate");
1644 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1645
1646 fs::remove_dir_all(&temp_dir).ok();
1648 }
1649
1650 #[test]
1651 fn test_resolve_default_function_fallback() {
1652 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1653 let _ = fs::remove_dir_all(&temp_dir);
1654 fs::create_dir_all(&temp_dir).unwrap();
1655
1656 let result = resolve_default_function(&temp_dir, "my-crate", None);
1658 assert_eq!(result, "my_crate::example_benchmark");
1659
1660 fs::remove_dir_all(&temp_dir).ok();
1662 }
1663
1664 #[test]
1665 fn test_sanitize_bundle_id_component() {
1666 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1668 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1670 assert_eq!(
1672 sanitize_bundle_id_component("my-project_name"),
1673 "myprojectname"
1674 );
1675 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1677 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1679 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1681 assert_eq!(
1683 sanitize_bundle_id_component("My-Complex_Project-123"),
1684 "mycomplexproject123"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_generate_ios_project_bundle_id_not_duplicated() {
1690 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1691 let _ = fs::remove_dir_all(&temp_dir);
1693 fs::create_dir_all(&temp_dir).unwrap();
1694
1695 let crate_name = "bench-mobile";
1697 let bundle_prefix = "dev.world.benchmobile";
1698 let project_pascal = "BenchRunner";
1699
1700 let result = generate_ios_project(
1701 &temp_dir,
1702 crate_name,
1703 project_pascal,
1704 bundle_prefix,
1705 "bench_mobile::test_func",
1706 );
1707 assert!(
1708 result.is_ok(),
1709 "generate_ios_project failed: {:?}",
1710 result.err()
1711 );
1712
1713 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1715 assert!(project_yml_path.exists(), "project.yml should exist");
1716
1717 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1719
1720 assert!(
1723 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1724 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1725 project_yml
1726 );
1727 assert!(
1728 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1729 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1730 project_yml
1731 );
1732 assert!(
1733 project_yml.contains("embed: false"),
1734 "Static xcframework dependency should be link-only, got:\n{}",
1735 project_yml
1736 );
1737
1738 fs::remove_dir_all(&temp_dir).ok();
1740 }
1741
1742 #[test]
1743 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1744 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1745 let _ = fs::remove_dir_all(&temp_dir);
1746 fs::create_dir_all(&temp_dir).unwrap();
1747
1748 generate_ios_project(
1749 &temp_dir,
1750 "bench_mobile",
1751 "BenchRunner",
1752 "dev.world.benchmobile",
1753 "bench_mobile::bench_prepare",
1754 )
1755 .unwrap();
1756
1757 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1758 fs::create_dir_all(resources_dir.join("nested")).unwrap();
1759 fs::write(
1760 resources_dir.join("bench_spec.json"),
1761 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1762 )
1763 .unwrap();
1764 fs::write(
1765 resources_dir.join("bench_meta.json"),
1766 r#"{"build_id":"build-123"}"#,
1767 )
1768 .unwrap();
1769 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1770
1771 generate_ios_project(
1772 &temp_dir,
1773 "bench_mobile",
1774 "BenchRunner",
1775 "dev.world.benchmobile",
1776 "bench_mobile::bench_prepare",
1777 )
1778 .unwrap();
1779
1780 assert_eq!(
1781 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1782 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1783 );
1784 assert_eq!(
1785 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1786 r#"{"build_id":"build-123"}"#
1787 );
1788 assert_eq!(
1789 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1790 "keep me"
1791 );
1792
1793 fs::remove_dir_all(&temp_dir).ok();
1794 }
1795
1796 #[test]
1797 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1798 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1799 let _ = fs::remove_dir_all(&temp_dir);
1800 fs::create_dir_all(&temp_dir).unwrap();
1801
1802 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1803 .expect("initial iOS project generation should succeed");
1804
1805 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1806 assert!(content_view_path.exists(), "ContentView.swift should exist");
1807
1808 fs::write(&content_view_path, "stale generated content").unwrap();
1809
1810 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1811 .expect("refreshing existing iOS project should succeed");
1812
1813 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1814 assert!(
1815 refreshed.contains("ProfileLaunchOptions"),
1816 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1817 refreshed
1818 );
1819 assert!(
1820 refreshed.contains("repeatUntilMs"),
1821 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1822 refreshed
1823 );
1824 assert!(
1825 refreshed.contains("Task.detached(priority: .userInitiated)"),
1826 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
1827 refreshed
1828 );
1829 assert!(
1830 refreshed.contains("await MainActor.run"),
1831 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
1832 refreshed
1833 );
1834
1835 fs::remove_dir_all(&temp_dir).ok();
1836 }
1837
1838 #[test]
1839 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
1840 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
1841 let _ = fs::remove_dir_all(&temp_dir);
1842 fs::create_dir_all(&temp_dir).unwrap();
1843
1844 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1845 .expect("initial iOS project generation should succeed");
1846
1847 let ui_test_path =
1848 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1849 assert!(
1850 ui_test_path.exists(),
1851 "BenchRunnerUITests.swift should exist"
1852 );
1853
1854 fs::write(&ui_test_path, "stale generated content").unwrap();
1855
1856 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1857 .expect("refreshing existing iOS project should succeed");
1858
1859 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
1860 assert!(
1861 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
1862 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
1863 refreshed
1864 );
1865 assert!(
1866 refreshed.contains(
1867 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
1868 ),
1869 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
1870 refreshed
1871 );
1872
1873 fs::remove_dir_all(&temp_dir).ok();
1874 }
1875
1876 #[test]
1877 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
1878 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1879 let _ = fs::remove_dir_all(&temp_dir);
1880 fs::create_dir_all(&temp_dir).unwrap();
1881
1882 let result = generate_ios_project_with_timeout(
1883 &temp_dir,
1884 "sample_fns",
1885 "BenchRunner",
1886 "dev.world.samplefns",
1887 "sample_fns::example_benchmark",
1888 1200,
1889 );
1890
1891 assert!(result.is_ok(), "generate_ios_project should succeed");
1892
1893 let ui_test_path =
1894 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1895 let contents = fs::read_to_string(&ui_test_path).unwrap();
1896 assert!(
1897 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
1898 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
1899 contents
1900 );
1901
1902 fs::remove_dir_all(&temp_dir).ok();
1903 }
1904
1905 #[test]
1906 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
1907 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
1908 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
1909 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
1910 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
1911 }
1912
1913 #[test]
1914 fn test_cross_platform_naming_consistency() {
1915 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1917 let _ = fs::remove_dir_all(&temp_dir);
1918 fs::create_dir_all(&temp_dir).unwrap();
1919
1920 let project_name = "bench-mobile";
1921
1922 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1924 assert!(
1925 result.is_ok(),
1926 "generate_android_project failed: {:?}",
1927 result.err()
1928 );
1929
1930 let bundle_id_component = sanitize_bundle_id_component(project_name);
1932 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1933 let result = generate_ios_project(
1934 &temp_dir,
1935 &project_name.replace('-', "_"),
1936 "BenchRunner",
1937 &bundle_prefix,
1938 "bench_mobile::test_func",
1939 );
1940 assert!(
1941 result.is_ok(),
1942 "generate_ios_project failed: {:?}",
1943 result.err()
1944 );
1945
1946 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1948 .expect("Failed to read Android build.gradle");
1949
1950 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1952 .expect("Failed to read iOS project.yml");
1953
1954 assert!(
1958 android_build_gradle.contains("dev.world.benchmobile"),
1959 "Android package should be 'dev.world.benchmobile', got:\n{}",
1960 android_build_gradle
1961 );
1962 assert!(
1963 ios_project_yml.contains("dev.world.benchmobile"),
1964 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1965 ios_project_yml
1966 );
1967
1968 assert!(
1970 !android_build_gradle.contains("dev.world.bench-mobile"),
1971 "Android package should NOT contain hyphens"
1972 );
1973 assert!(
1974 !android_build_gradle.contains("dev.world.bench_mobile"),
1975 "Android package should NOT contain underscores"
1976 );
1977
1978 fs::remove_dir_all(&temp_dir).ok();
1980 }
1981
1982 #[test]
1983 fn test_cross_platform_version_consistency() {
1984 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1986 let _ = fs::remove_dir_all(&temp_dir);
1987 fs::create_dir_all(&temp_dir).unwrap();
1988
1989 let project_name = "test-project";
1990
1991 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1993 assert!(
1994 result.is_ok(),
1995 "generate_android_project failed: {:?}",
1996 result.err()
1997 );
1998
1999 let bundle_id_component = sanitize_bundle_id_component(project_name);
2001 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2002 let result = generate_ios_project(
2003 &temp_dir,
2004 &project_name.replace('-', "_"),
2005 "BenchRunner",
2006 &bundle_prefix,
2007 "test_project::test_func",
2008 );
2009 assert!(
2010 result.is_ok(),
2011 "generate_ios_project failed: {:?}",
2012 result.err()
2013 );
2014
2015 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2017 .expect("Failed to read Android build.gradle");
2018
2019 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2021 .expect("Failed to read iOS project.yml");
2022
2023 assert!(
2025 android_build_gradle.contains("versionName \"1.0.0\""),
2026 "Android versionName should be '1.0.0', got:\n{}",
2027 android_build_gradle
2028 );
2029 assert!(
2030 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2031 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2032 ios_project_yml
2033 );
2034
2035 fs::remove_dir_all(&temp_dir).ok();
2037 }
2038
2039 #[test]
2040 fn test_bundle_id_prefix_consistency() {
2041 let test_cases = vec![
2043 ("my-project", "dev.world.myproject"),
2044 ("bench_mobile", "dev.world.benchmobile"),
2045 ("TestApp", "dev.world.testapp"),
2046 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2047 (
2048 "app_with_many_underscores",
2049 "dev.world.appwithmanyunderscores",
2050 ),
2051 ];
2052
2053 for (input, expected_prefix) in test_cases {
2054 let sanitized = sanitize_bundle_id_component(input);
2055 let full_prefix = format!("dev.world.{}", sanitized);
2056 assert_eq!(
2057 full_prefix, expected_prefix,
2058 "For input '{}', expected '{}' but got '{}'",
2059 input, expected_prefix, full_prefix
2060 );
2061 }
2062 }
2063}