1use crate::types::{BenchError, InitConfig, Target};
7use std::env;
8use std::fs;
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
12use include_dir::{Dir, DirEntry, include_dir};
13
14const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android");
15const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios");
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}
170
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
172pub struct SemanticPhase {
173 pub name: String,
174 pub duration_ns: u64,
175}
176
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
178pub struct HarnessTimelineSpan {
179 pub phase: String,
180 pub start_offset_ns: u64,
181 pub end_offset_ns: u64,
182 pub iteration: Option<u32>,
183}
184
185#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
186pub struct BenchReport {
187 pub spec: BenchSpec,
188 pub samples: Vec<BenchSample>,
189 pub phases: Vec<SemanticPhase>,
190 pub timeline: Vec<HarnessTimelineSpan>,
191}
192
193#[derive(Debug, thiserror::Error, uniffi::Error)]
194#[uniffi(flat_error)]
195pub enum BenchError {
196 #[error("iterations must be greater than zero")]
197 InvalidIterations,
198
199 #[error("unknown benchmark function: {name}")]
200 UnknownFunction { name: String },
201
202 #[error("benchmark execution failed: {reason}")]
203 ExecutionFailed { reason: String },
204}
205
206// Convert from mobench-sdk types
207impl From<mobench_sdk::BenchSpec> for BenchSpec {
208 fn from(spec: mobench_sdk::BenchSpec) -> Self {
209 Self {
210 name: spec.name,
211 iterations: spec.iterations,
212 warmup: spec.warmup,
213 }
214 }
215}
216
217impl From<BenchSpec> for mobench_sdk::BenchSpec {
218 fn from(spec: BenchSpec) -> Self {
219 Self {
220 name: spec.name,
221 iterations: spec.iterations,
222 warmup: spec.warmup,
223 }
224 }
225}
226
227impl From<mobench_sdk::BenchSample> for BenchSample {
228 fn from(sample: mobench_sdk::BenchSample) -> Self {
229 Self {
230 duration_ns: sample.duration_ns,
231 }
232 }
233}
234
235impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
236 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
237 Self {
238 name: phase.name,
239 duration_ns: phase.duration_ns,
240 }
241 }
242}
243
244impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
245 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
246 Self {
247 phase: span.phase,
248 start_offset_ns: span.start_offset_ns,
249 end_offset_ns: span.end_offset_ns,
250 iteration: span.iteration,
251 }
252 }
253}
254
255impl From<mobench_sdk::RunnerReport> for BenchReport {
256 fn from(report: mobench_sdk::RunnerReport) -> Self {
257 Self {
258 spec: report.spec.into(),
259 samples: report.samples.into_iter().map(Into::into).collect(),
260 phases: report.phases.into_iter().map(Into::into).collect(),
261 timeline: report.timeline.into_iter().map(Into::into).collect(),
262 }
263 }
264}
265
266impl From<mobench_sdk::BenchError> for BenchError {
267 fn from(err: mobench_sdk::BenchError) -> Self {
268 match err {
269 mobench_sdk::BenchError::Runner(runner_err) => {
270 BenchError::ExecutionFailed {
271 reason: runner_err.to_string(),
272 }
273 }
274 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
275 BenchError::UnknownFunction { name }
276 }
277 _ => BenchError::ExecutionFailed {
278 reason: err.to_string(),
279 },
280 }
281 }
282}
283
284/// Runs a benchmark by name with the given specification
285///
286/// This is the main FFI entry point called from mobile platforms.
287#[uniffi::export]
288pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
289 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
290 let report = mobench_sdk::run_benchmark(sdk_spec)?;
291 Ok(report.into())
292}
293
294// Generate UniFFI scaffolding
295uniffi::setup_scaffolding!();
296"#;
297
298 let lib_rs = render_template(
299 lib_rs_template,
300 &[TemplateVar {
301 name: "USER_CRATE",
302 value: project_name.replace('-', "_"),
303 }],
304 );
305 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
306
307 let build_rs = r#"fn main() {
309 uniffi::generate_scaffolding("src/lib.rs").unwrap();
310}
311"#;
312
313 fs::write(crate_dir.join("build.rs"), build_rs)?;
314
315 let bin_dir = crate_dir.join("src/bin");
317 fs::create_dir_all(&bin_dir)?;
318 let uniffi_bindgen_rs = r#"fn main() {
319 uniffi::uniffi_bindgen_main()
320}
321"#;
322 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
323
324 Ok(())
325}
326
327pub fn generate_android_project(
338 output_dir: &Path,
339 project_slug: &str,
340 default_function: &str,
341) -> Result<(), BenchError> {
342 let target_dir = output_dir.join("android");
343 let preserved_assets = collect_preserved_android_assets(&target_dir)?;
344 reset_generated_project_dir(&target_dir)?;
345 let library_name = project_slug.replace('-', "_");
346 let project_pascal = to_pascal_case(project_slug);
347 let package_id_component = sanitize_bundle_id_component(project_slug);
350 let package_name = format!("dev.world.{}", package_id_component);
351 let vars = vec![
352 TemplateVar {
353 name: "PROJECT_NAME",
354 value: project_slug.to_string(),
355 },
356 TemplateVar {
357 name: "PROJECT_NAME_PASCAL",
358 value: project_pascal.clone(),
359 },
360 TemplateVar {
361 name: "APP_NAME",
362 value: format!("{} Benchmark", project_pascal),
363 },
364 TemplateVar {
365 name: "PACKAGE_NAME",
366 value: package_name.clone(),
367 },
368 TemplateVar {
369 name: "UNIFFI_NAMESPACE",
370 value: library_name.clone(),
371 },
372 TemplateVar {
373 name: "LIBRARY_NAME",
374 value: library_name,
375 },
376 TemplateVar {
377 name: "DEFAULT_FUNCTION",
378 value: default_function.to_string(),
379 },
380 ];
381 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
382 restore_preserved_android_assets(&target_dir, &preserved_assets)?;
383
384 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
387
388 Ok(())
389}
390
391fn collect_preserved_android_assets(
392 target_dir: &Path,
393) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
394 let assets_dir = target_dir.join("app/src/main/assets");
395 let mut preserved = Vec::new();
396
397 if assets_dir.exists() {
398 collect_preserved_files(&assets_dir, &assets_dir, &mut preserved)?;
399 }
400
401 Ok(preserved)
402}
403
404fn collect_preserved_files(
405 root: &Path,
406 current: &Path,
407 preserved: &mut Vec<(PathBuf, Vec<u8>)>,
408) -> Result<(), BenchError> {
409 let mut entries = fs::read_dir(current)?
410 .collect::<Result<Vec<_>, _>>()
411 .map_err(BenchError::Io)?;
412 entries.sort_by_key(|entry| entry.path());
413
414 for entry in entries {
415 let path = entry.path();
416 if path.is_dir() {
417 collect_preserved_files(root, &path, preserved)?;
418 continue;
419 }
420
421 let relative = path.strip_prefix(root).map_err(|e| {
422 BenchError::Build(format!(
423 "Failed to preserve Android asset {:?}: {}",
424 path, e
425 ))
426 })?;
427 preserved.push((relative.to_path_buf(), fs::read(&path)?));
428 }
429
430 Ok(())
431}
432
433fn restore_preserved_android_assets(
434 target_dir: &Path,
435 preserved_assets: &[(PathBuf, Vec<u8>)],
436) -> Result<(), BenchError> {
437 if preserved_assets.is_empty() {
438 return Ok(());
439 }
440
441 let assets_dir = target_dir.join("app/src/main/assets");
442 for (relative, contents) in preserved_assets {
443 let asset_path = assets_dir.join(relative);
444 if let Some(parent) = asset_path.parent() {
445 fs::create_dir_all(parent)?;
446 }
447 fs::write(asset_path, contents)?;
448 }
449
450 Ok(())
451}
452
453fn collect_preserved_ios_resources(
454 target_dir: &Path,
455) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
456 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
457 let mut preserved = Vec::new();
458
459 if resources_dir.exists() {
460 collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
461 }
462
463 Ok(preserved)
464}
465
466fn restore_preserved_ios_resources(
467 target_dir: &Path,
468 preserved_resources: &[(PathBuf, Vec<u8>)],
469) -> Result<(), BenchError> {
470 if preserved_resources.is_empty() {
471 return Ok(());
472 }
473
474 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
475 for (relative, contents) in preserved_resources {
476 let resource_path = resources_dir.join(relative);
477 if let Some(parent) = resource_path.parent() {
478 fs::create_dir_all(parent)?;
479 }
480 fs::write(resource_path, contents)?;
481 }
482
483 Ok(())
484}
485
486fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
487 if target_dir.exists() {
488 fs::remove_dir_all(target_dir).map_err(|e| {
489 BenchError::Build(format!(
490 "Failed to clear existing generated project at {:?}: {}",
491 target_dir, e
492 ))
493 })?;
494 }
495 Ok(())
496}
497
498fn move_kotlin_files_to_package_dir(
508 android_dir: &Path,
509 package_name: &str,
510) -> Result<(), BenchError> {
511 let package_path = package_name.replace('.', "/");
513
514 let main_java_dir = android_dir.join("app/src/main/java");
516 let main_package_dir = main_java_dir.join(&package_path);
517 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
518
519 let test_java_dir = android_dir.join("app/src/androidTest/java");
521 let test_package_dir = test_java_dir.join(&package_path);
522 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
523
524 Ok(())
525}
526
527fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
529 let src_file = src_dir.join(filename);
530 if !src_file.exists() {
531 return Ok(());
533 }
534
535 fs::create_dir_all(dest_dir).map_err(|e| {
537 BenchError::Build(format!(
538 "Failed to create package directory {:?}: {}",
539 dest_dir, e
540 ))
541 })?;
542
543 let dest_file = dest_dir.join(filename);
544
545 fs::copy(&src_file, &dest_file).map_err(|e| {
547 BenchError::Build(format!(
548 "Failed to copy {} to {:?}: {}",
549 filename, dest_file, e
550 ))
551 })?;
552
553 fs::remove_file(&src_file).map_err(|e| {
554 BenchError::Build(format!(
555 "Failed to remove original file {:?}: {}",
556 src_file, e
557 ))
558 })?;
559
560 Ok(())
561}
562
563pub fn generate_ios_project(
576 output_dir: &Path,
577 project_slug: &str,
578 project_pascal: &str,
579 bundle_prefix: &str,
580 default_function: &str,
581) -> Result<(), BenchError> {
582 let target_dir = output_dir.join("ios");
583 let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
584 reset_generated_project_dir(&target_dir)?;
585 let sanitized_bundle_prefix = {
588 let parts: Vec<&str> = bundle_prefix.split('.').collect();
589 parts
590 .iter()
591 .map(|part| sanitize_bundle_id_component(part))
592 .collect::<Vec<_>>()
593 .join(".")
594 };
595 let vars = vec![
599 TemplateVar {
600 name: "DEFAULT_FUNCTION",
601 value: default_function.to_string(),
602 },
603 TemplateVar {
604 name: "PROJECT_NAME_PASCAL",
605 value: project_pascal.to_string(),
606 },
607 TemplateVar {
608 name: "BUNDLE_ID_PREFIX",
609 value: sanitized_bundle_prefix.clone(),
610 },
611 TemplateVar {
612 name: "BUNDLE_ID",
613 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
614 },
615 TemplateVar {
616 name: "LIBRARY_NAME",
617 value: project_slug.replace('-', "_"),
618 },
619 TemplateVar {
620 name: "BENCHMARK_TIMEOUT_SECS",
621 value: ios_benchmark_timeout_secs(),
622 },
623 ];
624 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
625 restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
626 Ok(())
627}
628
629fn ios_benchmark_timeout_secs() -> String {
630 match env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS") {
631 Ok(raw_value) => {
632 let trimmed = raw_value.trim();
633 match trimmed.parse::<u64>() {
634 Ok(timeout_secs) if timeout_secs > 0 => format!("{timeout_secs}.0"),
635 _ => {
636 eprintln!(
637 "Ignoring invalid MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS='{}'; falling back to 300.0",
638 raw_value
639 );
640 "300.0".to_string()
641 }
642 }
643 }
644 Err(_) => "300.0".to_string(),
645 }
646}
647
648fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
650 let config_target = match config.target {
651 Target::Ios => "ios",
652 Target::Android | Target::Both => "android",
653 };
654 let config_content = format!(
655 r#"# mobench configuration
656# This file controls how benchmarks are executed on devices.
657
658target = "{}"
659function = "example_fibonacci"
660iterations = 100
661warmup = 10
662device_matrix = "device-matrix.yaml"
663device_tags = ["default"]
664
665[browserstack]
666app_automate_username = "${{BROWSERSTACK_USERNAME}}"
667app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
668project = "{}-benchmarks"
669
670[ios_xcuitest]
671app = "target/ios/BenchRunner.ipa"
672test_suite = "target/ios/BenchRunnerUITests.zip"
673"#,
674 config_target, config.project_name
675 );
676
677 fs::write(output_dir.join("bench-config.toml"), config_content)?;
678
679 Ok(())
680}
681
682fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
684 let examples_dir = output_dir.join("benches");
685 fs::create_dir_all(&examples_dir)?;
686
687 let example_content = r#"//! Example benchmarks
688//!
689//! This file demonstrates how to write benchmarks with mobench-sdk.
690
691use mobench_sdk::benchmark;
692
693/// Simple benchmark example
694#[benchmark]
695fn example_fibonacci() {
696 let result = fibonacci(30);
697 std::hint::black_box(result);
698}
699
700/// Another example with a loop
701#[benchmark]
702fn example_sum() {
703 let mut sum = 0u64;
704 for i in 0..10000 {
705 sum = sum.wrapping_add(i);
706 }
707 std::hint::black_box(sum);
708}
709
710// Helper function (not benchmarked)
711fn fibonacci(n: u32) -> u64 {
712 match n {
713 0 => 0,
714 1 => 1,
715 _ => {
716 let mut a = 0u64;
717 let mut b = 1u64;
718 for _ in 2..=n {
719 let next = a.wrapping_add(b);
720 a = b;
721 b = next;
722 }
723 b
724 }
725 }
726}
727"#;
728
729 fs::write(examples_dir.join("example.rs"), example_content)?;
730
731 Ok(())
732}
733
734const TEMPLATE_EXTENSIONS: &[&str] = &[
736 "gradle",
737 "xml",
738 "kt",
739 "java",
740 "swift",
741 "yml",
742 "yaml",
743 "json",
744 "toml",
745 "md",
746 "txt",
747 "h",
748 "m",
749 "plist",
750 "pbxproj",
751 "xcscheme",
752 "xcworkspacedata",
753 "entitlements",
754 "modulemap",
755];
756
757fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
758 for entry in dir.entries() {
759 match entry {
760 DirEntry::Dir(sub) => {
761 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
763 continue;
764 }
765 render_dir(sub, out_root, vars)?;
766 }
767 DirEntry::File(file) => {
768 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
769 continue;
770 }
771 let mut relative = file.path().to_path_buf();
773 let mut contents = file.contents().to_vec();
774
775 let is_explicit_template = relative
777 .extension()
778 .map(|ext| ext == "template")
779 .unwrap_or(false);
780
781 let should_render = is_explicit_template || is_template_file(&relative);
783
784 if is_explicit_template {
785 relative.set_extension("");
787 }
788
789 if should_render {
790 if let Ok(text) = std::str::from_utf8(&contents) {
791 let rendered = render_template(text, vars);
792 validate_no_unreplaced_placeholders(&rendered, &relative)?;
794 contents = rendered.into_bytes();
795 }
796 }
797
798 let out_path = out_root.join(relative);
799 if let Some(parent) = out_path.parent() {
800 fs::create_dir_all(parent)?;
801 }
802 fs::write(&out_path, contents)?;
803 }
804 }
805 }
806 Ok(())
807}
808
809fn is_template_file(path: &Path) -> bool {
812 if let Some(ext) = path.extension() {
814 if ext == "template" {
815 return true;
816 }
817 if let Some(ext_str) = ext.to_str() {
819 return TEMPLATE_EXTENSIONS.contains(&ext_str);
820 }
821 }
822 if let Some(stem) = path.file_stem() {
824 let stem_path = Path::new(stem);
825 if let Some(ext) = stem_path.extension() {
826 if let Some(ext_str) = ext.to_str() {
827 return TEMPLATE_EXTENSIONS.contains(&ext_str);
828 }
829 }
830 }
831 false
832}
833
834fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
836 let mut pos = 0;
838 let mut unreplaced = Vec::new();
839
840 while let Some(start) = content[pos..].find("{{") {
841 let abs_start = pos + start;
842 if let Some(end) = content[abs_start..].find("}}") {
843 let placeholder = &content[abs_start..abs_start + end + 2];
844 let var_name = &content[abs_start + 2..abs_start + end];
846 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
849 unreplaced.push(placeholder.to_string());
850 }
851 pos = abs_start + end + 2;
852 } else {
853 break;
854 }
855 }
856
857 if !unreplaced.is_empty() {
858 return Err(BenchError::Build(format!(
859 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
860 This is a bug in mobench-sdk. Please report it at:\n\
861 https://github.com/worldcoin/mobile-bench-rs/issues",
862 file_path, unreplaced
863 )));
864 }
865
866 Ok(())
867}
868
869fn render_template(input: &str, vars: &[TemplateVar]) -> String {
870 let mut output = input.to_string();
871 for var in vars {
872 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
873 }
874 output
875}
876
877pub fn sanitize_bundle_id_component(name: &str) -> String {
888 name.chars()
889 .filter(|c| c.is_ascii_alphanumeric())
890 .collect::<String>()
891 .to_lowercase()
892}
893
894fn sanitize_package_name(name: &str) -> String {
895 name.chars()
896 .map(|c| {
897 if c.is_ascii_alphanumeric() {
898 c.to_ascii_lowercase()
899 } else {
900 '-'
901 }
902 })
903 .collect::<String>()
904 .trim_matches('-')
905 .replace("--", "-")
906}
907
908pub fn to_pascal_case(input: &str) -> String {
910 input
911 .split(|c: char| !c.is_ascii_alphanumeric())
912 .filter(|s| !s.is_empty())
913 .map(|s| {
914 let mut chars = s.chars();
915 let first = chars.next().unwrap().to_ascii_uppercase();
916 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
917 format!("{}{}", first, rest)
918 })
919 .collect::<String>()
920}
921
922pub fn android_project_exists(output_dir: &Path) -> bool {
926 let android_dir = output_dir.join("android");
927 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
928}
929
930pub fn ios_project_exists(output_dir: &Path) -> bool {
934 output_dir.join("ios/BenchRunner/project.yml").exists()
935}
936
937fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
942 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
943 let Ok(content) = std::fs::read_to_string(&project_yml) else {
944 return false;
945 };
946 let expected = format!("../{}.xcframework", library_name);
947 content.contains(&expected)
948}
949
950fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
955 let build_gradle = output_dir.join("android/app/build.gradle");
956 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
957 return false;
958 };
959 let expected = format!("lib{}.so", library_name);
960 content.contains(&expected)
961}
962
963pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
978 let lib_rs = crate_dir.join("src/lib.rs");
979 if !lib_rs.exists() {
980 return None;
981 }
982
983 let file = fs::File::open(&lib_rs).ok()?;
984 let reader = BufReader::new(file);
985
986 let mut found_benchmark_attr = false;
987 let crate_name_normalized = crate_name.replace('-', "_");
988
989 for line in reader.lines().map_while(Result::ok) {
990 let trimmed = line.trim();
991
992 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
994 found_benchmark_attr = true;
995 continue;
996 }
997
998 if found_benchmark_attr {
1000 if let Some(fn_pos) = trimmed.find("fn ") {
1002 let after_fn = &trimmed[fn_pos + 3..];
1003 let fn_name: String = after_fn
1005 .chars()
1006 .take_while(|c| c.is_alphanumeric() || *c == '_')
1007 .collect();
1008
1009 if !fn_name.is_empty() {
1010 return Some(format!("{}::{}", crate_name_normalized, fn_name));
1011 }
1012 }
1013 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1016 found_benchmark_attr = false;
1017 }
1018 }
1019 }
1020
1021 None
1022}
1023
1024pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1038 let lib_rs = crate_dir.join("src/lib.rs");
1039 if !lib_rs.exists() {
1040 return Vec::new();
1041 }
1042
1043 let Ok(file) = fs::File::open(&lib_rs) else {
1044 return Vec::new();
1045 };
1046 let reader = BufReader::new(file);
1047
1048 let mut benchmarks = Vec::new();
1049 let mut found_benchmark_attr = false;
1050 let crate_name_normalized = crate_name.replace('-', "_");
1051
1052 for line in reader.lines().map_while(Result::ok) {
1053 let trimmed = line.trim();
1054
1055 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1057 found_benchmark_attr = true;
1058 continue;
1059 }
1060
1061 if found_benchmark_attr {
1063 if let Some(fn_pos) = trimmed.find("fn ") {
1065 let after_fn = &trimmed[fn_pos + 3..];
1066 let fn_name: String = after_fn
1068 .chars()
1069 .take_while(|c| c.is_alphanumeric() || *c == '_')
1070 .collect();
1071
1072 if !fn_name.is_empty() {
1073 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1074 }
1075 found_benchmark_attr = false;
1076 }
1077 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1080 found_benchmark_attr = false;
1081 }
1082 }
1083 }
1084
1085 benchmarks
1086}
1087
1088pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1100 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1101 let crate_name_normalized = crate_name.replace('-', "_");
1102
1103 let normalized_name = if function_name.contains("::") {
1105 function_name.to_string()
1106 } else {
1107 format!("{}::{}", crate_name_normalized, function_name)
1108 };
1109
1110 benchmarks.iter().any(|b| b == &normalized_name)
1111}
1112
1113pub fn resolve_default_function(
1128 project_root: &Path,
1129 crate_name: &str,
1130 crate_dir: Option<&Path>,
1131) -> String {
1132 let crate_name_normalized = crate_name.replace('-', "_");
1133
1134 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1136 vec![dir.to_path_buf()]
1137 } else {
1138 vec![
1139 project_root.join("bench-mobile"),
1140 project_root.join("crates").join(crate_name),
1141 project_root.to_path_buf(),
1142 ]
1143 };
1144
1145 for dir in &search_dirs {
1147 if dir.join("Cargo.toml").exists() {
1148 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
1149 return detected;
1150 }
1151 }
1152 }
1153
1154 format!("{}::example_benchmark", crate_name_normalized)
1156}
1157
1158pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1169 ensure_android_project_with_options(output_dir, crate_name, None, None)
1170}
1171
1172pub fn ensure_android_project_with_options(
1184 output_dir: &Path,
1185 crate_name: &str,
1186 project_root: Option<&Path>,
1187 crate_dir: Option<&Path>,
1188) -> Result<(), BenchError> {
1189 let library_name = crate_name.replace('-', "_");
1190 if android_project_exists(output_dir)
1191 && android_project_matches_library(output_dir, &library_name)
1192 {
1193 return Ok(());
1194 }
1195
1196 println!("Android project not found, generating scaffolding...");
1197 let project_slug = crate_name.replace('-', "_");
1198
1199 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1201 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1202
1203 generate_android_project(output_dir, &project_slug, &default_function)?;
1204 println!(
1205 " Generated Android project at {:?}",
1206 output_dir.join("android")
1207 );
1208 println!(" Default benchmark function: {}", default_function);
1209 Ok(())
1210}
1211
1212pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1223 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1224}
1225
1226pub fn ensure_ios_project_with_options(
1238 output_dir: &Path,
1239 crate_name: &str,
1240 project_root: Option<&Path>,
1241 crate_dir: Option<&Path>,
1242) -> Result<(), BenchError> {
1243 let library_name = crate_name.replace('-', "_");
1244 let project_exists = ios_project_exists(output_dir);
1245 let project_matches = ios_project_matches_library(output_dir, &library_name);
1246 if project_exists && !project_matches {
1247 println!("Existing iOS scaffolding does not match library, regenerating...");
1248 } else if project_exists {
1249 println!("Refreshing generated iOS scaffolding...");
1250 } else {
1251 println!("iOS project not found, generating scaffolding...");
1252 }
1253
1254 let project_pascal = "BenchRunner";
1256 let library_name = crate_name.replace('-', "_");
1258 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1261 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1262
1263 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1265 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1266
1267 generate_ios_project(
1268 output_dir,
1269 &library_name,
1270 project_pascal,
1271 &bundle_prefix,
1272 &default_function,
1273 )?;
1274 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1275 println!(" Default benchmark function: {}", default_function);
1276 Ok(())
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use std::env;
1283
1284 #[test]
1285 fn test_generate_bench_mobile_crate() {
1286 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1287 fs::create_dir_all(&temp_dir).unwrap();
1288
1289 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1290 assert!(result.is_ok());
1291
1292 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1294 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1295 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1296
1297 fs::remove_dir_all(&temp_dir).ok();
1299 }
1300
1301 #[test]
1302 fn test_generate_android_project_no_unreplaced_placeholders() {
1303 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1304 let _ = fs::remove_dir_all(&temp_dir);
1306 fs::create_dir_all(&temp_dir).unwrap();
1307
1308 let result =
1309 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1310 assert!(
1311 result.is_ok(),
1312 "generate_android_project failed: {:?}",
1313 result.err()
1314 );
1315
1316 let android_dir = temp_dir.join("android");
1318 assert!(android_dir.join("settings.gradle").exists());
1319 assert!(android_dir.join("app/build.gradle").exists());
1320 assert!(
1321 android_dir
1322 .join("app/src/main/AndroidManifest.xml")
1323 .exists()
1324 );
1325 assert!(
1326 android_dir
1327 .join("app/src/main/res/values/strings.xml")
1328 .exists()
1329 );
1330 assert!(
1331 android_dir
1332 .join("app/src/main/res/values/themes.xml")
1333 .exists()
1334 );
1335
1336 let files_to_check = [
1338 "settings.gradle",
1339 "app/build.gradle",
1340 "app/src/main/AndroidManifest.xml",
1341 "app/src/main/res/values/strings.xml",
1342 "app/src/main/res/values/themes.xml",
1343 ];
1344
1345 for file in files_to_check {
1346 let path = android_dir.join(file);
1347 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1348
1349 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1351 assert!(
1352 !has_placeholder,
1353 "File {} contains unreplaced template placeholders: {}",
1354 file, contents
1355 );
1356 }
1357
1358 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1360 assert!(
1361 settings.contains("my-bench-project-android")
1362 || settings.contains("my_bench_project-android"),
1363 "settings.gradle should contain project name"
1364 );
1365
1366 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1367 assert!(
1369 build_gradle.contains("dev.world.mybenchproject"),
1370 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1371 );
1372
1373 let manifest =
1374 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1375 assert!(
1376 manifest.contains("Theme.MyBenchProject"),
1377 "AndroidManifest.xml should contain PascalCase theme name"
1378 );
1379
1380 let strings =
1381 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1382 assert!(
1383 strings.contains("Benchmark"),
1384 "strings.xml should contain app name with Benchmark"
1385 );
1386
1387 let main_activity_path =
1390 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1391 assert!(
1392 main_activity_path.exists(),
1393 "MainActivity.kt should be in package directory: {:?}",
1394 main_activity_path
1395 );
1396
1397 let test_activity_path = android_dir
1398 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1399 assert!(
1400 test_activity_path.exists(),
1401 "MainActivityTest.kt should be in package directory: {:?}",
1402 test_activity_path
1403 );
1404
1405 assert!(
1407 !android_dir
1408 .join("app/src/main/java/MainActivity.kt")
1409 .exists(),
1410 "MainActivity.kt should not be in root java directory"
1411 );
1412 assert!(
1413 !android_dir
1414 .join("app/src/androidTest/java/MainActivityTest.kt")
1415 .exists(),
1416 "MainActivityTest.kt should not be in root java directory"
1417 );
1418
1419 fs::remove_dir_all(&temp_dir).ok();
1421 }
1422
1423 #[test]
1424 fn test_generate_android_project_replaces_previous_package_tree() {
1425 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1426 let _ = fs::remove_dir_all(&temp_dir);
1427 fs::create_dir_all(&temp_dir).unwrap();
1428
1429 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1430 .unwrap();
1431 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1432 assert!(
1433 old_package_dir.exists(),
1434 "expected first package tree to exist"
1435 );
1436
1437 generate_android_project(
1438 &temp_dir,
1439 "basic_benchmark",
1440 "basic_benchmark::bench_fibonacci",
1441 )
1442 .unwrap();
1443
1444 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1445 assert!(
1446 new_package_dir.exists(),
1447 "expected new package tree to exist"
1448 );
1449 assert!(
1450 !old_package_dir.exists(),
1451 "old package tree should be removed when regenerating the Android scaffold"
1452 );
1453
1454 fs::remove_dir_all(&temp_dir).ok();
1455 }
1456
1457 #[test]
1458 fn test_generate_android_project_preserves_existing_assets_on_regeneration() {
1459 let temp_dir = env::temp_dir().join("mobench-sdk-android-assets-regenerate-test");
1460 let _ = fs::remove_dir_all(&temp_dir);
1461 fs::create_dir_all(&temp_dir).unwrap();
1462
1463 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1464 .unwrap();
1465
1466 let assets_dir = temp_dir.join("android/app/src/main/assets");
1467 fs::create_dir_all(assets_dir.join("nested")).unwrap();
1468 fs::write(
1469 assets_dir.join("bench_spec.json"),
1470 r#"{"function":"ffi_benchmark::bench_fibonacci"}"#,
1471 )
1472 .unwrap();
1473 fs::write(
1474 assets_dir.join("bench_meta.json"),
1475 r#"{"build_id":"build-123"}"#,
1476 )
1477 .unwrap();
1478 fs::write(assets_dir.join("nested/custom.txt"), "keep me").unwrap();
1479
1480 generate_android_project(
1481 &temp_dir,
1482 "basic_benchmark",
1483 "basic_benchmark::bench_fibonacci",
1484 )
1485 .unwrap();
1486
1487 assert_eq!(
1488 fs::read_to_string(assets_dir.join("bench_spec.json")).unwrap(),
1489 r#"{"function":"ffi_benchmark::bench_fibonacci"}"#
1490 );
1491 assert_eq!(
1492 fs::read_to_string(assets_dir.join("bench_meta.json")).unwrap(),
1493 r#"{"build_id":"build-123"}"#
1494 );
1495 assert_eq!(
1496 fs::read_to_string(assets_dir.join("nested/custom.txt")).unwrap(),
1497 "keep me"
1498 );
1499
1500 fs::remove_dir_all(&temp_dir).ok();
1501 }
1502
1503 #[test]
1504 fn test_is_template_file() {
1505 assert!(is_template_file(Path::new("settings.gradle")));
1506 assert!(is_template_file(Path::new("app/build.gradle")));
1507 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1508 assert!(is_template_file(Path::new("strings.xml")));
1509 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1510 assert!(is_template_file(Path::new("project.yml")));
1511 assert!(is_template_file(Path::new("Info.plist")));
1512 assert!(!is_template_file(Path::new("libfoo.so")));
1513 assert!(!is_template_file(Path::new("image.png")));
1514 }
1515
1516 #[test]
1517 fn test_validate_no_unreplaced_placeholders() {
1518 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1520
1521 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1523
1524 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1526 assert!(result.is_err());
1527 let err = result.unwrap_err().to_string();
1528 assert!(err.contains("{{NAME}}"));
1529 }
1530
1531 #[test]
1532 fn test_to_pascal_case() {
1533 assert_eq!(to_pascal_case("my-project"), "MyProject");
1534 assert_eq!(to_pascal_case("my_project"), "MyProject");
1535 assert_eq!(to_pascal_case("myproject"), "Myproject");
1536 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1537 }
1538
1539 #[test]
1540 fn test_detect_default_function_finds_benchmark() {
1541 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1542 let _ = fs::remove_dir_all(&temp_dir);
1543 fs::create_dir_all(temp_dir.join("src")).unwrap();
1544
1545 let lib_content = r#"
1547use mobench_sdk::benchmark;
1548
1549/// Some docs
1550#[benchmark]
1551fn my_benchmark_func() {
1552 // benchmark code
1553}
1554
1555fn helper_func() {}
1556"#;
1557 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1558 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1559
1560 let result = detect_default_function(&temp_dir, "my_crate");
1561 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1562
1563 fs::remove_dir_all(&temp_dir).ok();
1565 }
1566
1567 #[test]
1568 fn test_detect_default_function_no_benchmark() {
1569 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1570 let _ = fs::remove_dir_all(&temp_dir);
1571 fs::create_dir_all(temp_dir.join("src")).unwrap();
1572
1573 let lib_content = r#"
1575fn regular_function() {
1576 // no benchmark here
1577}
1578"#;
1579 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1580
1581 let result = detect_default_function(&temp_dir, "my_crate");
1582 assert!(result.is_none());
1583
1584 fs::remove_dir_all(&temp_dir).ok();
1586 }
1587
1588 #[test]
1589 fn test_detect_default_function_pub_fn() {
1590 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1591 let _ = fs::remove_dir_all(&temp_dir);
1592 fs::create_dir_all(temp_dir.join("src")).unwrap();
1593
1594 let lib_content = r#"
1596#[benchmark]
1597pub fn public_bench() {
1598 // benchmark code
1599}
1600"#;
1601 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1602
1603 let result = detect_default_function(&temp_dir, "test-crate");
1604 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1605
1606 fs::remove_dir_all(&temp_dir).ok();
1608 }
1609
1610 #[test]
1611 fn test_resolve_default_function_fallback() {
1612 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1613 let _ = fs::remove_dir_all(&temp_dir);
1614 fs::create_dir_all(&temp_dir).unwrap();
1615
1616 let result = resolve_default_function(&temp_dir, "my-crate", None);
1618 assert_eq!(result, "my_crate::example_benchmark");
1619
1620 fs::remove_dir_all(&temp_dir).ok();
1622 }
1623
1624 #[test]
1625 fn test_sanitize_bundle_id_component() {
1626 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1628 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1630 assert_eq!(
1632 sanitize_bundle_id_component("my-project_name"),
1633 "myprojectname"
1634 );
1635 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1637 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1639 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1641 assert_eq!(
1643 sanitize_bundle_id_component("My-Complex_Project-123"),
1644 "mycomplexproject123"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_generate_ios_project_bundle_id_not_duplicated() {
1650 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1651 let _ = fs::remove_dir_all(&temp_dir);
1653 fs::create_dir_all(&temp_dir).unwrap();
1654
1655 let crate_name = "bench-mobile";
1657 let bundle_prefix = "dev.world.benchmobile";
1658 let project_pascal = "BenchRunner";
1659
1660 let result = generate_ios_project(
1661 &temp_dir,
1662 crate_name,
1663 project_pascal,
1664 bundle_prefix,
1665 "bench_mobile::test_func",
1666 );
1667 assert!(
1668 result.is_ok(),
1669 "generate_ios_project failed: {:?}",
1670 result.err()
1671 );
1672
1673 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1675 assert!(project_yml_path.exists(), "project.yml should exist");
1676
1677 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1679
1680 assert!(
1683 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1684 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1685 project_yml
1686 );
1687 assert!(
1688 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1689 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1690 project_yml
1691 );
1692 assert!(
1693 project_yml.contains("embed: false"),
1694 "Static xcframework dependency should be link-only, got:\n{}",
1695 project_yml
1696 );
1697
1698 fs::remove_dir_all(&temp_dir).ok();
1700 }
1701
1702 #[test]
1703 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1704 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1705 let _ = fs::remove_dir_all(&temp_dir);
1706 fs::create_dir_all(&temp_dir).unwrap();
1707
1708 generate_ios_project(
1709 &temp_dir,
1710 "bench_mobile",
1711 "BenchRunner",
1712 "dev.world.benchmobile",
1713 "bench_mobile::bench_prepare",
1714 )
1715 .unwrap();
1716
1717 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1718 fs::create_dir_all(resources_dir.join("nested")).unwrap();
1719 fs::write(
1720 resources_dir.join("bench_spec.json"),
1721 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1722 )
1723 .unwrap();
1724 fs::write(
1725 resources_dir.join("bench_meta.json"),
1726 r#"{"build_id":"build-123"}"#,
1727 )
1728 .unwrap();
1729 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1730
1731 generate_ios_project(
1732 &temp_dir,
1733 "bench_mobile",
1734 "BenchRunner",
1735 "dev.world.benchmobile",
1736 "bench_mobile::bench_prepare",
1737 )
1738 .unwrap();
1739
1740 assert_eq!(
1741 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1742 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1743 );
1744 assert_eq!(
1745 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1746 r#"{"build_id":"build-123"}"#
1747 );
1748 assert_eq!(
1749 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1750 "keep me"
1751 );
1752
1753 fs::remove_dir_all(&temp_dir).ok();
1754 }
1755
1756 #[test]
1757 fn test_generate_ios_project_uses_configured_timeout_in_ui_tests() {
1758 use std::sync::{Mutex, OnceLock};
1759
1760 static IOS_TIMEOUT_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1761 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1762 let _ = fs::remove_dir_all(&temp_dir);
1763 fs::create_dir_all(&temp_dir).unwrap();
1764
1765 let _guard = IOS_TIMEOUT_ENV_LOCK
1766 .get_or_init(|| Mutex::new(()))
1767 .lock()
1768 .unwrap();
1769 unsafe {
1770 env::set_var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS", "600");
1771 }
1772
1773 generate_ios_project(
1774 &temp_dir,
1775 "bench_mobile",
1776 "BenchRunner",
1777 "dev.world.benchmobile",
1778 "bench_mobile::bench_prepare",
1779 )
1780 .unwrap();
1781
1782 let ui_test_path = temp_dir.join(
1783 "ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift",
1784 );
1785 let ui_test = fs::read_to_string(&ui_test_path)
1786 .expect("BenchRunnerUITests.swift should exist");
1787
1788 assert!(
1789 ui_test.contains("benchmarkTimeout: TimeInterval = 600.0"),
1790 "BenchRunnerUITests.swift should use the configured timeout, got:\n{}",
1791 ui_test
1792 );
1793
1794 unsafe {
1795 env::remove_var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS");
1796 }
1797 fs::remove_dir_all(&temp_dir).ok();
1798 }
1799
1800 #[test]
1801 fn test_generate_ios_project_includes_raw_resource_metrics_in_json_report() {
1802 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resource-metrics-test");
1803 let _ = fs::remove_dir_all(&temp_dir);
1804 fs::create_dir_all(&temp_dir).unwrap();
1805
1806 let result = generate_ios_project(
1807 &temp_dir,
1808 "bench_mobile",
1809 "BenchRunner",
1810 "dev.world.benchmobile",
1811 "bench_mobile::bench_passport_complete_age_check_prove",
1812 );
1813 assert!(
1814 result.is_ok(),
1815 "generate_ios_project failed: {:?}",
1816 result.err()
1817 );
1818
1819 let ffi_path = temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift");
1820 let ffi = fs::read_to_string(&ffi_path).expect("BenchRunnerFFI.swift should exist");
1821
1822 assert!(
1823 ffi.contains("\"elapsed_cpu_ms\""),
1824 "BenchRunnerFFI.swift should emit elapsed_cpu_ms into raw resources, got:\n{}",
1825 ffi
1826 );
1827 assert!(
1828 ffi.contains("\"peak_memory_kb\""),
1829 "BenchRunnerFFI.swift should emit peak_memory_kb into raw resources, got:\n{}",
1830 ffi
1831 );
1832
1833 fs::remove_dir_all(&temp_dir).ok();
1834 }
1835
1836 #[test]
1837 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1838 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1839 let _ = fs::remove_dir_all(&temp_dir);
1840 fs::create_dir_all(&temp_dir).unwrap();
1841
1842 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1843 .expect("initial iOS project generation should succeed");
1844
1845 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1846 assert!(content_view_path.exists(), "ContentView.swift should exist");
1847
1848 fs::write(&content_view_path, "stale generated content").unwrap();
1849
1850 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1851 .expect("refreshing existing iOS project should succeed");
1852
1853 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1854 assert!(
1855 refreshed.contains("ProfileLaunchOptions"),
1856 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1857 refreshed
1858 );
1859 assert!(
1860 refreshed.contains("repeatUntilMs"),
1861 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1862 refreshed
1863 );
1864
1865 fs::remove_dir_all(&temp_dir).ok();
1866 }
1867
1868 #[test]
1869 fn test_cross_platform_naming_consistency() {
1870 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1872 let _ = fs::remove_dir_all(&temp_dir);
1873 fs::create_dir_all(&temp_dir).unwrap();
1874
1875 let project_name = "bench-mobile";
1876
1877 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1879 assert!(
1880 result.is_ok(),
1881 "generate_android_project failed: {:?}",
1882 result.err()
1883 );
1884
1885 let bundle_id_component = sanitize_bundle_id_component(project_name);
1887 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1888 let result = generate_ios_project(
1889 &temp_dir,
1890 &project_name.replace('-', "_"),
1891 "BenchRunner",
1892 &bundle_prefix,
1893 "bench_mobile::test_func",
1894 );
1895 assert!(
1896 result.is_ok(),
1897 "generate_ios_project failed: {:?}",
1898 result.err()
1899 );
1900
1901 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1903 .expect("Failed to read Android build.gradle");
1904
1905 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1907 .expect("Failed to read iOS project.yml");
1908
1909 assert!(
1913 android_build_gradle.contains("dev.world.benchmobile"),
1914 "Android package should be 'dev.world.benchmobile', got:\n{}",
1915 android_build_gradle
1916 );
1917 assert!(
1918 ios_project_yml.contains("dev.world.benchmobile"),
1919 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1920 ios_project_yml
1921 );
1922
1923 assert!(
1925 !android_build_gradle.contains("dev.world.bench-mobile"),
1926 "Android package should NOT contain hyphens"
1927 );
1928 assert!(
1929 !android_build_gradle.contains("dev.world.bench_mobile"),
1930 "Android package should NOT contain underscores"
1931 );
1932
1933 fs::remove_dir_all(&temp_dir).ok();
1935 }
1936
1937 #[test]
1938 fn test_cross_platform_version_consistency() {
1939 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1941 let _ = fs::remove_dir_all(&temp_dir);
1942 fs::create_dir_all(&temp_dir).unwrap();
1943
1944 let project_name = "test-project";
1945
1946 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1948 assert!(
1949 result.is_ok(),
1950 "generate_android_project failed: {:?}",
1951 result.err()
1952 );
1953
1954 let bundle_id_component = sanitize_bundle_id_component(project_name);
1956 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1957 let result = generate_ios_project(
1958 &temp_dir,
1959 &project_name.replace('-', "_"),
1960 "BenchRunner",
1961 &bundle_prefix,
1962 "test_project::test_func",
1963 );
1964 assert!(
1965 result.is_ok(),
1966 "generate_ios_project failed: {:?}",
1967 result.err()
1968 );
1969
1970 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1972 .expect("Failed to read Android build.gradle");
1973
1974 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1976 .expect("Failed to read iOS project.yml");
1977
1978 assert!(
1980 android_build_gradle.contains("versionName \"1.0.0\""),
1981 "Android versionName should be '1.0.0', got:\n{}",
1982 android_build_gradle
1983 );
1984 assert!(
1985 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1986 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1987 ios_project_yml
1988 );
1989
1990 fs::remove_dir_all(&temp_dir).ok();
1992 }
1993
1994 #[test]
1995 fn test_bundle_id_prefix_consistency() {
1996 let test_cases = vec![
1998 ("my-project", "dev.world.myproject"),
1999 ("bench_mobile", "dev.world.benchmobile"),
2000 ("TestApp", "dev.world.testapp"),
2001 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2002 (
2003 "app_with_many_underscores",
2004 "dev.world.appwithmanyunderscores",
2005 ),
2006 ];
2007
2008 for (input, expected_prefix) in test_cases {
2009 let sanitized = sanitize_bundle_id_component(input);
2010 let full_prefix = format!("dev.world.{}", sanitized);
2011 assert_eq!(
2012 full_prefix, expected_prefix,
2013 "For input '{}', expected '{}' but got '{}'",
2014 input, expected_prefix, full_prefix
2015 );
2016 }
2017 }
2018}