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