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");
15
16#[derive(Debug, Clone)]
18pub struct TemplateVar {
19 pub name: &'static str,
20 pub value: String,
21}
22
23pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
40 let output_dir = &config.output_dir;
41 let project_slug = sanitize_package_name(&config.project_name);
42 let project_pascal = to_pascal_case(&project_slug);
43 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
45 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
46
47 fs::create_dir_all(output_dir)?;
49
50 generate_bench_mobile_crate(output_dir, &project_slug)?;
52
53 let default_function = "example_fibonacci";
56
57 match config.target {
59 Target::Android => {
60 generate_android_project(output_dir, &project_slug, default_function)?;
61 }
62 Target::Ios => {
63 generate_ios_project(
64 output_dir,
65 &project_slug,
66 &project_pascal,
67 &bundle_prefix,
68 default_function,
69 )?;
70 }
71 Target::Both => {
72 generate_android_project(output_dir, &project_slug, default_function)?;
73 generate_ios_project(
74 output_dir,
75 &project_slug,
76 &project_pascal,
77 &bundle_prefix,
78 default_function,
79 )?;
80 }
81 }
82
83 generate_config_file(output_dir, config)?;
85
86 if config.generate_examples {
88 generate_example_benchmarks(output_dir)?;
89 }
90
91 Ok(output_dir.clone())
92}
93
94fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
96 let crate_dir = output_dir.join("bench-mobile");
97 fs::create_dir_all(crate_dir.join("src"))?;
98
99 let crate_name = format!("{}-bench-mobile", project_name);
100
101 let cargo_toml = format!(
105 r#"[package]
106name = "{}"
107version = "0.1.0"
108edition = "2021"
109
110[lib]
111crate-type = ["cdylib", "staticlib", "rlib"]
112
113[dependencies]
114mobench-sdk = {{ path = ".." }}
115uniffi = "0.28"
116{} = {{ path = ".." }}
117
118[features]
119default = []
120
121[build-dependencies]
122uniffi = {{ version = "0.28", features = ["build"] }}
123
124# Binary for generating UniFFI bindings (used by mobench build)
125[[bin]]
126name = "uniffi-bindgen"
127path = "src/bin/uniffi-bindgen.rs"
128
129# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
130# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
131# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
132#
133# Add this to your root Cargo.toml:
134# [workspace.dependencies]
135# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
136#
137# Then in each crate that uses rustls:
138# [dependencies]
139# rustls = {{ workspace = true }}
140"#,
141 crate_name, project_name
142 );
143
144 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
145
146 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
148//!
149//! This crate provides the FFI boundary between Rust benchmarks and mobile
150//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
151
152use uniffi;
153
154// Ensure the user crate is linked so benchmark registrations are pulled in.
155extern crate {{USER_CRATE}} as _bench_user_crate;
156
157// Re-export mobench-sdk types with UniFFI annotations
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
159pub struct BenchSpec {
160 pub name: String,
161 pub iterations: u32,
162 pub warmup: u32,
163}
164
165#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
166pub struct BenchSample {
167 pub duration_ns: u64,
168}
169
170#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
171pub struct SemanticPhase {
172 pub name: String,
173 pub duration_ns: u64,
174}
175
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
177pub struct BenchReport {
178 pub spec: BenchSpec,
179 pub samples: Vec<BenchSample>,
180 pub phases: Vec<SemanticPhase>,
181}
182
183#[derive(Debug, thiserror::Error, uniffi::Error)]
184#[uniffi(flat_error)]
185pub enum BenchError {
186 #[error("iterations must be greater than zero")]
187 InvalidIterations,
188
189 #[error("unknown benchmark function: {name}")]
190 UnknownFunction { name: String },
191
192 #[error("benchmark execution failed: {reason}")]
193 ExecutionFailed { reason: String },
194}
195
196// Convert from mobench-sdk types
197impl From<mobench_sdk::BenchSpec> for BenchSpec {
198 fn from(spec: mobench_sdk::BenchSpec) -> Self {
199 Self {
200 name: spec.name,
201 iterations: spec.iterations,
202 warmup: spec.warmup,
203 }
204 }
205}
206
207impl From<BenchSpec> for mobench_sdk::BenchSpec {
208 fn from(spec: BenchSpec) -> Self {
209 Self {
210 name: spec.name,
211 iterations: spec.iterations,
212 warmup: spec.warmup,
213 }
214 }
215}
216
217impl From<mobench_sdk::BenchSample> for BenchSample {
218 fn from(sample: mobench_sdk::BenchSample) -> Self {
219 Self {
220 duration_ns: sample.duration_ns,
221 }
222 }
223}
224
225impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
226 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
227 Self {
228 name: phase.name,
229 duration_ns: phase.duration_ns,
230 }
231 }
232}
233
234impl From<mobench_sdk::RunnerReport> for BenchReport {
235 fn from(report: mobench_sdk::RunnerReport) -> Self {
236 Self {
237 spec: report.spec.into(),
238 samples: report.samples.into_iter().map(Into::into).collect(),
239 phases: report.phases.into_iter().map(Into::into).collect(),
240 }
241 }
242}
243
244impl From<mobench_sdk::BenchError> for BenchError {
245 fn from(err: mobench_sdk::BenchError) -> Self {
246 match err {
247 mobench_sdk::BenchError::Runner(runner_err) => {
248 BenchError::ExecutionFailed {
249 reason: runner_err.to_string(),
250 }
251 }
252 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
253 BenchError::UnknownFunction { name }
254 }
255 _ => BenchError::ExecutionFailed {
256 reason: err.to_string(),
257 },
258 }
259 }
260}
261
262/// Runs a benchmark by name with the given specification
263///
264/// This is the main FFI entry point called from mobile platforms.
265#[uniffi::export]
266pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
267 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
268 let report = mobench_sdk::run_benchmark(sdk_spec)?;
269 Ok(report.into())
270}
271
272// Generate UniFFI scaffolding
273uniffi::setup_scaffolding!();
274"#;
275
276 let lib_rs = render_template(
277 lib_rs_template,
278 &[TemplateVar {
279 name: "USER_CRATE",
280 value: project_name.replace('-', "_"),
281 }],
282 );
283 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
284
285 let build_rs = r#"fn main() {
287 uniffi::generate_scaffolding("src/lib.rs").unwrap();
288}
289"#;
290
291 fs::write(crate_dir.join("build.rs"), build_rs)?;
292
293 let bin_dir = crate_dir.join("src/bin");
295 fs::create_dir_all(&bin_dir)?;
296 let uniffi_bindgen_rs = r#"fn main() {
297 uniffi::uniffi_bindgen_main()
298}
299"#;
300 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
301
302 Ok(())
303}
304
305pub fn generate_android_project(
316 output_dir: &Path,
317 project_slug: &str,
318 default_function: &str,
319) -> Result<(), BenchError> {
320 let target_dir = output_dir.join("android");
321 reset_generated_project_dir(&target_dir)?;
322 let library_name = project_slug.replace('-', "_");
323 let project_pascal = to_pascal_case(project_slug);
324 let package_id_component = sanitize_bundle_id_component(project_slug);
327 let package_name = format!("dev.world.{}", package_id_component);
328 let vars = vec![
329 TemplateVar {
330 name: "PROJECT_NAME",
331 value: project_slug.to_string(),
332 },
333 TemplateVar {
334 name: "PROJECT_NAME_PASCAL",
335 value: project_pascal.clone(),
336 },
337 TemplateVar {
338 name: "APP_NAME",
339 value: format!("{} Benchmark", project_pascal),
340 },
341 TemplateVar {
342 name: "PACKAGE_NAME",
343 value: package_name.clone(),
344 },
345 TemplateVar {
346 name: "UNIFFI_NAMESPACE",
347 value: library_name.clone(),
348 },
349 TemplateVar {
350 name: "LIBRARY_NAME",
351 value: library_name,
352 },
353 TemplateVar {
354 name: "DEFAULT_FUNCTION",
355 value: default_function.to_string(),
356 },
357 ];
358 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
359
360 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
363
364 Ok(())
365}
366
367fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
368 if target_dir.exists() {
369 fs::remove_dir_all(target_dir).map_err(|e| {
370 BenchError::Build(format!(
371 "Failed to clear existing generated project at {:?}: {}",
372 target_dir, e
373 ))
374 })?;
375 }
376 Ok(())
377}
378
379fn move_kotlin_files_to_package_dir(
389 android_dir: &Path,
390 package_name: &str,
391) -> Result<(), BenchError> {
392 let package_path = package_name.replace('.', "/");
394
395 let main_java_dir = android_dir.join("app/src/main/java");
397 let main_package_dir = main_java_dir.join(&package_path);
398 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
399
400 let test_java_dir = android_dir.join("app/src/androidTest/java");
402 let test_package_dir = test_java_dir.join(&package_path);
403 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
404
405 Ok(())
406}
407
408fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
410 let src_file = src_dir.join(filename);
411 if !src_file.exists() {
412 return Ok(());
414 }
415
416 fs::create_dir_all(dest_dir).map_err(|e| {
418 BenchError::Build(format!(
419 "Failed to create package directory {:?}: {}",
420 dest_dir, e
421 ))
422 })?;
423
424 let dest_file = dest_dir.join(filename);
425
426 fs::copy(&src_file, &dest_file).map_err(|e| {
428 BenchError::Build(format!(
429 "Failed to copy {} to {:?}: {}",
430 filename, dest_file, e
431 ))
432 })?;
433
434 fs::remove_file(&src_file).map_err(|e| {
435 BenchError::Build(format!(
436 "Failed to remove original file {:?}: {}",
437 src_file, e
438 ))
439 })?;
440
441 Ok(())
442}
443
444pub fn generate_ios_project(
457 output_dir: &Path,
458 project_slug: &str,
459 project_pascal: &str,
460 bundle_prefix: &str,
461 default_function: &str,
462) -> Result<(), BenchError> {
463 let target_dir = output_dir.join("ios");
464 reset_generated_project_dir(&target_dir)?;
465 let sanitized_bundle_prefix = {
468 let parts: Vec<&str> = bundle_prefix.split('.').collect();
469 parts
470 .iter()
471 .map(|part| sanitize_bundle_id_component(part))
472 .collect::<Vec<_>>()
473 .join(".")
474 };
475 let vars = vec![
479 TemplateVar {
480 name: "DEFAULT_FUNCTION",
481 value: default_function.to_string(),
482 },
483 TemplateVar {
484 name: "PROJECT_NAME_PASCAL",
485 value: project_pascal.to_string(),
486 },
487 TemplateVar {
488 name: "BUNDLE_ID_PREFIX",
489 value: sanitized_bundle_prefix.clone(),
490 },
491 TemplateVar {
492 name: "BUNDLE_ID",
493 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
494 },
495 TemplateVar {
496 name: "LIBRARY_NAME",
497 value: project_slug.replace('-', "_"),
498 },
499 ];
500 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
501 Ok(())
502}
503
504fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
506 let config_target = match config.target {
507 Target::Ios => "ios",
508 Target::Android | Target::Both => "android",
509 };
510 let config_content = format!(
511 r#"# mobench configuration
512# This file controls how benchmarks are executed on devices.
513
514target = "{}"
515function = "example_fibonacci"
516iterations = 100
517warmup = 10
518device_matrix = "device-matrix.yaml"
519device_tags = ["default"]
520
521[browserstack]
522app_automate_username = "${{BROWSERSTACK_USERNAME}}"
523app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
524project = "{}-benchmarks"
525
526[ios_xcuitest]
527app = "target/ios/BenchRunner.ipa"
528test_suite = "target/ios/BenchRunnerUITests.zip"
529"#,
530 config_target, config.project_name
531 );
532
533 fs::write(output_dir.join("bench-config.toml"), config_content)?;
534
535 Ok(())
536}
537
538fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
540 let examples_dir = output_dir.join("benches");
541 fs::create_dir_all(&examples_dir)?;
542
543 let example_content = r#"//! Example benchmarks
544//!
545//! This file demonstrates how to write benchmarks with mobench-sdk.
546
547use mobench_sdk::benchmark;
548
549/// Simple benchmark example
550#[benchmark]
551fn example_fibonacci() {
552 let result = fibonacci(30);
553 std::hint::black_box(result);
554}
555
556/// Another example with a loop
557#[benchmark]
558fn example_sum() {
559 let mut sum = 0u64;
560 for i in 0..10000 {
561 sum = sum.wrapping_add(i);
562 }
563 std::hint::black_box(sum);
564}
565
566// Helper function (not benchmarked)
567fn fibonacci(n: u32) -> u64 {
568 match n {
569 0 => 0,
570 1 => 1,
571 _ => {
572 let mut a = 0u64;
573 let mut b = 1u64;
574 for _ in 2..=n {
575 let next = a.wrapping_add(b);
576 a = b;
577 b = next;
578 }
579 b
580 }
581 }
582}
583"#;
584
585 fs::write(examples_dir.join("example.rs"), example_content)?;
586
587 Ok(())
588}
589
590const TEMPLATE_EXTENSIONS: &[&str] = &[
592 "gradle",
593 "xml",
594 "kt",
595 "java",
596 "swift",
597 "yml",
598 "yaml",
599 "json",
600 "toml",
601 "md",
602 "txt",
603 "h",
604 "m",
605 "plist",
606 "pbxproj",
607 "xcscheme",
608 "xcworkspacedata",
609 "entitlements",
610 "modulemap",
611];
612
613fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
614 for entry in dir.entries() {
615 match entry {
616 DirEntry::Dir(sub) => {
617 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
619 continue;
620 }
621 render_dir(sub, out_root, vars)?;
622 }
623 DirEntry::File(file) => {
624 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
625 continue;
626 }
627 let mut relative = file.path().to_path_buf();
629 let mut contents = file.contents().to_vec();
630
631 let is_explicit_template = relative
633 .extension()
634 .map(|ext| ext == "template")
635 .unwrap_or(false);
636
637 let should_render = is_explicit_template || is_template_file(&relative);
639
640 if is_explicit_template {
641 relative.set_extension("");
643 }
644
645 if should_render {
646 if let Ok(text) = std::str::from_utf8(&contents) {
647 let rendered = render_template(text, vars);
648 validate_no_unreplaced_placeholders(&rendered, &relative)?;
650 contents = rendered.into_bytes();
651 }
652 }
653
654 let out_path = out_root.join(relative);
655 if let Some(parent) = out_path.parent() {
656 fs::create_dir_all(parent)?;
657 }
658 fs::write(&out_path, contents)?;
659 }
660 }
661 }
662 Ok(())
663}
664
665fn is_template_file(path: &Path) -> bool {
668 if let Some(ext) = path.extension() {
670 if ext == "template" {
671 return true;
672 }
673 if let Some(ext_str) = ext.to_str() {
675 return TEMPLATE_EXTENSIONS.contains(&ext_str);
676 }
677 }
678 if let Some(stem) = path.file_stem() {
680 let stem_path = Path::new(stem);
681 if let Some(ext) = stem_path.extension() {
682 if let Some(ext_str) = ext.to_str() {
683 return TEMPLATE_EXTENSIONS.contains(&ext_str);
684 }
685 }
686 }
687 false
688}
689
690fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
692 let mut pos = 0;
694 let mut unreplaced = Vec::new();
695
696 while let Some(start) = content[pos..].find("{{") {
697 let abs_start = pos + start;
698 if let Some(end) = content[abs_start..].find("}}") {
699 let placeholder = &content[abs_start..abs_start + end + 2];
700 let var_name = &content[abs_start + 2..abs_start + end];
702 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
705 unreplaced.push(placeholder.to_string());
706 }
707 pos = abs_start + end + 2;
708 } else {
709 break;
710 }
711 }
712
713 if !unreplaced.is_empty() {
714 return Err(BenchError::Build(format!(
715 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
716 This is a bug in mobench-sdk. Please report it at:\n\
717 https://github.com/worldcoin/mobile-bench-rs/issues",
718 file_path, unreplaced
719 )));
720 }
721
722 Ok(())
723}
724
725fn render_template(input: &str, vars: &[TemplateVar]) -> String {
726 let mut output = input.to_string();
727 for var in vars {
728 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
729 }
730 output
731}
732
733pub fn sanitize_bundle_id_component(name: &str) -> String {
744 name.chars()
745 .filter(|c| c.is_ascii_alphanumeric())
746 .collect::<String>()
747 .to_lowercase()
748}
749
750fn sanitize_package_name(name: &str) -> String {
751 name.chars()
752 .map(|c| {
753 if c.is_ascii_alphanumeric() {
754 c.to_ascii_lowercase()
755 } else {
756 '-'
757 }
758 })
759 .collect::<String>()
760 .trim_matches('-')
761 .replace("--", "-")
762}
763
764pub fn to_pascal_case(input: &str) -> String {
766 input
767 .split(|c: char| !c.is_ascii_alphanumeric())
768 .filter(|s| !s.is_empty())
769 .map(|s| {
770 let mut chars = s.chars();
771 let first = chars.next().unwrap().to_ascii_uppercase();
772 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
773 format!("{}{}", first, rest)
774 })
775 .collect::<String>()
776}
777
778pub fn android_project_exists(output_dir: &Path) -> bool {
782 let android_dir = output_dir.join("android");
783 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
784}
785
786pub fn ios_project_exists(output_dir: &Path) -> bool {
790 output_dir.join("ios/BenchRunner/project.yml").exists()
791}
792
793fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
798 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
799 let Ok(content) = std::fs::read_to_string(&project_yml) else {
800 return false;
801 };
802 let expected = format!("../{}.xcframework", library_name);
803 content.contains(&expected)
804}
805
806fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
811 let build_gradle = output_dir.join("android/app/build.gradle");
812 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
813 return false;
814 };
815 let expected = format!("lib{}.so", library_name);
816 content.contains(&expected)
817}
818
819pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
834 let lib_rs = crate_dir.join("src/lib.rs");
835 if !lib_rs.exists() {
836 return None;
837 }
838
839 let file = fs::File::open(&lib_rs).ok()?;
840 let reader = BufReader::new(file);
841
842 let mut found_benchmark_attr = false;
843 let crate_name_normalized = crate_name.replace('-', "_");
844
845 for line in reader.lines().map_while(Result::ok) {
846 let trimmed = line.trim();
847
848 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
850 found_benchmark_attr = true;
851 continue;
852 }
853
854 if found_benchmark_attr {
856 if let Some(fn_pos) = trimmed.find("fn ") {
858 let after_fn = &trimmed[fn_pos + 3..];
859 let fn_name: String = after_fn
861 .chars()
862 .take_while(|c| c.is_alphanumeric() || *c == '_')
863 .collect();
864
865 if !fn_name.is_empty() {
866 return Some(format!("{}::{}", crate_name_normalized, fn_name));
867 }
868 }
869 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
872 found_benchmark_attr = false;
873 }
874 }
875 }
876
877 None
878}
879
880pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
894 let lib_rs = crate_dir.join("src/lib.rs");
895 if !lib_rs.exists() {
896 return Vec::new();
897 }
898
899 let Ok(file) = fs::File::open(&lib_rs) else {
900 return Vec::new();
901 };
902 let reader = BufReader::new(file);
903
904 let mut benchmarks = Vec::new();
905 let mut found_benchmark_attr = false;
906 let crate_name_normalized = crate_name.replace('-', "_");
907
908 for line in reader.lines().map_while(Result::ok) {
909 let trimmed = line.trim();
910
911 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
913 found_benchmark_attr = true;
914 continue;
915 }
916
917 if found_benchmark_attr {
919 if let Some(fn_pos) = trimmed.find("fn ") {
921 let after_fn = &trimmed[fn_pos + 3..];
922 let fn_name: String = after_fn
924 .chars()
925 .take_while(|c| c.is_alphanumeric() || *c == '_')
926 .collect();
927
928 if !fn_name.is_empty() {
929 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
930 }
931 found_benchmark_attr = false;
932 }
933 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
936 found_benchmark_attr = false;
937 }
938 }
939 }
940
941 benchmarks
942}
943
944pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
956 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
957 let crate_name_normalized = crate_name.replace('-', "_");
958
959 let normalized_name = if function_name.contains("::") {
961 function_name.to_string()
962 } else {
963 format!("{}::{}", crate_name_normalized, function_name)
964 };
965
966 benchmarks.iter().any(|b| b == &normalized_name)
967}
968
969pub fn resolve_default_function(
984 project_root: &Path,
985 crate_name: &str,
986 crate_dir: Option<&Path>,
987) -> String {
988 let crate_name_normalized = crate_name.replace('-', "_");
989
990 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
992 vec![dir.to_path_buf()]
993 } else {
994 vec![
995 project_root.join("bench-mobile"),
996 project_root.join("crates").join(crate_name),
997 project_root.to_path_buf(),
998 ]
999 };
1000
1001 for dir in &search_dirs {
1003 if dir.join("Cargo.toml").exists() {
1004 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
1005 return detected;
1006 }
1007 }
1008 }
1009
1010 format!("{}::example_benchmark", crate_name_normalized)
1012}
1013
1014pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1025 ensure_android_project_with_options(output_dir, crate_name, None, None)
1026}
1027
1028pub fn ensure_android_project_with_options(
1040 output_dir: &Path,
1041 crate_name: &str,
1042 project_root: Option<&Path>,
1043 crate_dir: Option<&Path>,
1044) -> Result<(), BenchError> {
1045 let library_name = crate_name.replace('-', "_");
1046 if android_project_exists(output_dir)
1047 && android_project_matches_library(output_dir, &library_name)
1048 {
1049 return Ok(());
1050 }
1051
1052 println!("Android project not found, generating scaffolding...");
1053 let project_slug = crate_name.replace('-', "_");
1054
1055 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1057 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1058
1059 generate_android_project(output_dir, &project_slug, &default_function)?;
1060 println!(
1061 " Generated Android project at {:?}",
1062 output_dir.join("android")
1063 );
1064 println!(" Default benchmark function: {}", default_function);
1065 Ok(())
1066}
1067
1068pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1079 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1080}
1081
1082pub fn ensure_ios_project_with_options(
1094 output_dir: &Path,
1095 crate_name: &str,
1096 project_root: Option<&Path>,
1097 crate_dir: Option<&Path>,
1098) -> Result<(), BenchError> {
1099 let library_name = crate_name.replace('-', "_");
1100 let project_exists = ios_project_exists(output_dir);
1101 let project_matches = ios_project_matches_library(output_dir, &library_name);
1102 if project_exists && !project_matches {
1103 println!("Existing iOS scaffolding does not match library, regenerating...");
1104 } else if project_exists {
1105 println!("Refreshing generated iOS scaffolding...");
1106 } else {
1107 println!("iOS project not found, generating scaffolding...");
1108 }
1109
1110 let project_pascal = "BenchRunner";
1112 let library_name = crate_name.replace('-', "_");
1114 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1117 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1118
1119 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1121 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1122
1123 generate_ios_project(
1124 output_dir,
1125 &library_name,
1126 project_pascal,
1127 &bundle_prefix,
1128 &default_function,
1129 )?;
1130 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1131 println!(" Default benchmark function: {}", default_function);
1132 Ok(())
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 use super::*;
1138 use std::env;
1139
1140 #[test]
1141 fn test_generate_bench_mobile_crate() {
1142 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1143 fs::create_dir_all(&temp_dir).unwrap();
1144
1145 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1146 assert!(result.is_ok());
1147
1148 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1150 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1151 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1152
1153 fs::remove_dir_all(&temp_dir).ok();
1155 }
1156
1157 #[test]
1158 fn test_generate_android_project_no_unreplaced_placeholders() {
1159 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1160 let _ = fs::remove_dir_all(&temp_dir);
1162 fs::create_dir_all(&temp_dir).unwrap();
1163
1164 let result =
1165 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1166 assert!(
1167 result.is_ok(),
1168 "generate_android_project failed: {:?}",
1169 result.err()
1170 );
1171
1172 let android_dir = temp_dir.join("android");
1174 assert!(android_dir.join("settings.gradle").exists());
1175 assert!(android_dir.join("app/build.gradle").exists());
1176 assert!(
1177 android_dir
1178 .join("app/src/main/AndroidManifest.xml")
1179 .exists()
1180 );
1181 assert!(
1182 android_dir
1183 .join("app/src/main/res/values/strings.xml")
1184 .exists()
1185 );
1186 assert!(
1187 android_dir
1188 .join("app/src/main/res/values/themes.xml")
1189 .exists()
1190 );
1191
1192 let files_to_check = [
1194 "settings.gradle",
1195 "app/build.gradle",
1196 "app/src/main/AndroidManifest.xml",
1197 "app/src/main/res/values/strings.xml",
1198 "app/src/main/res/values/themes.xml",
1199 ];
1200
1201 for file in files_to_check {
1202 let path = android_dir.join(file);
1203 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1204
1205 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1207 assert!(
1208 !has_placeholder,
1209 "File {} contains unreplaced template placeholders: {}",
1210 file, contents
1211 );
1212 }
1213
1214 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1216 assert!(
1217 settings.contains("my-bench-project-android")
1218 || settings.contains("my_bench_project-android"),
1219 "settings.gradle should contain project name"
1220 );
1221
1222 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1223 assert!(
1225 build_gradle.contains("dev.world.mybenchproject"),
1226 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1227 );
1228
1229 let manifest =
1230 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1231 assert!(
1232 manifest.contains("Theme.MyBenchProject"),
1233 "AndroidManifest.xml should contain PascalCase theme name"
1234 );
1235
1236 let strings =
1237 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1238 assert!(
1239 strings.contains("Benchmark"),
1240 "strings.xml should contain app name with Benchmark"
1241 );
1242
1243 let main_activity_path =
1246 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1247 assert!(
1248 main_activity_path.exists(),
1249 "MainActivity.kt should be in package directory: {:?}",
1250 main_activity_path
1251 );
1252
1253 let test_activity_path = android_dir
1254 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1255 assert!(
1256 test_activity_path.exists(),
1257 "MainActivityTest.kt should be in package directory: {:?}",
1258 test_activity_path
1259 );
1260
1261 assert!(
1263 !android_dir
1264 .join("app/src/main/java/MainActivity.kt")
1265 .exists(),
1266 "MainActivity.kt should not be in root java directory"
1267 );
1268 assert!(
1269 !android_dir
1270 .join("app/src/androidTest/java/MainActivityTest.kt")
1271 .exists(),
1272 "MainActivityTest.kt should not be in root java directory"
1273 );
1274
1275 fs::remove_dir_all(&temp_dir).ok();
1277 }
1278
1279 #[test]
1280 fn test_generate_android_project_replaces_previous_package_tree() {
1281 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1282 let _ = fs::remove_dir_all(&temp_dir);
1283 fs::create_dir_all(&temp_dir).unwrap();
1284
1285 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1286 .unwrap();
1287 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1288 assert!(old_package_dir.exists(), "expected first package tree to exist");
1289
1290 generate_android_project(
1291 &temp_dir,
1292 "basic_benchmark",
1293 "basic_benchmark::bench_fibonacci",
1294 )
1295 .unwrap();
1296
1297 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1298 assert!(new_package_dir.exists(), "expected new package tree to exist");
1299 assert!(
1300 !old_package_dir.exists(),
1301 "old package tree should be removed when regenerating the Android scaffold"
1302 );
1303
1304 fs::remove_dir_all(&temp_dir).ok();
1305 }
1306
1307 #[test]
1308 fn test_is_template_file() {
1309 assert!(is_template_file(Path::new("settings.gradle")));
1310 assert!(is_template_file(Path::new("app/build.gradle")));
1311 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1312 assert!(is_template_file(Path::new("strings.xml")));
1313 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1314 assert!(is_template_file(Path::new("project.yml")));
1315 assert!(is_template_file(Path::new("Info.plist")));
1316 assert!(!is_template_file(Path::new("libfoo.so")));
1317 assert!(!is_template_file(Path::new("image.png")));
1318 }
1319
1320 #[test]
1321 fn test_validate_no_unreplaced_placeholders() {
1322 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1324
1325 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1327
1328 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1330 assert!(result.is_err());
1331 let err = result.unwrap_err().to_string();
1332 assert!(err.contains("{{NAME}}"));
1333 }
1334
1335 #[test]
1336 fn test_to_pascal_case() {
1337 assert_eq!(to_pascal_case("my-project"), "MyProject");
1338 assert_eq!(to_pascal_case("my_project"), "MyProject");
1339 assert_eq!(to_pascal_case("myproject"), "Myproject");
1340 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1341 }
1342
1343 #[test]
1344 fn test_detect_default_function_finds_benchmark() {
1345 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1346 let _ = fs::remove_dir_all(&temp_dir);
1347 fs::create_dir_all(temp_dir.join("src")).unwrap();
1348
1349 let lib_content = r#"
1351use mobench_sdk::benchmark;
1352
1353/// Some docs
1354#[benchmark]
1355fn my_benchmark_func() {
1356 // benchmark code
1357}
1358
1359fn helper_func() {}
1360"#;
1361 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1362 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1363
1364 let result = detect_default_function(&temp_dir, "my_crate");
1365 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1366
1367 fs::remove_dir_all(&temp_dir).ok();
1369 }
1370
1371 #[test]
1372 fn test_detect_default_function_no_benchmark() {
1373 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1374 let _ = fs::remove_dir_all(&temp_dir);
1375 fs::create_dir_all(temp_dir.join("src")).unwrap();
1376
1377 let lib_content = r#"
1379fn regular_function() {
1380 // no benchmark here
1381}
1382"#;
1383 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1384
1385 let result = detect_default_function(&temp_dir, "my_crate");
1386 assert!(result.is_none());
1387
1388 fs::remove_dir_all(&temp_dir).ok();
1390 }
1391
1392 #[test]
1393 fn test_detect_default_function_pub_fn() {
1394 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1395 let _ = fs::remove_dir_all(&temp_dir);
1396 fs::create_dir_all(temp_dir.join("src")).unwrap();
1397
1398 let lib_content = r#"
1400#[benchmark]
1401pub fn public_bench() {
1402 // benchmark code
1403}
1404"#;
1405 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1406
1407 let result = detect_default_function(&temp_dir, "test-crate");
1408 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1409
1410 fs::remove_dir_all(&temp_dir).ok();
1412 }
1413
1414 #[test]
1415 fn test_resolve_default_function_fallback() {
1416 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1417 let _ = fs::remove_dir_all(&temp_dir);
1418 fs::create_dir_all(&temp_dir).unwrap();
1419
1420 let result = resolve_default_function(&temp_dir, "my-crate", None);
1422 assert_eq!(result, "my_crate::example_benchmark");
1423
1424 fs::remove_dir_all(&temp_dir).ok();
1426 }
1427
1428 #[test]
1429 fn test_sanitize_bundle_id_component() {
1430 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1432 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1434 assert_eq!(
1436 sanitize_bundle_id_component("my-project_name"),
1437 "myprojectname"
1438 );
1439 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1441 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1443 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1445 assert_eq!(
1447 sanitize_bundle_id_component("My-Complex_Project-123"),
1448 "mycomplexproject123"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_generate_ios_project_bundle_id_not_duplicated() {
1454 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1455 let _ = fs::remove_dir_all(&temp_dir);
1457 fs::create_dir_all(&temp_dir).unwrap();
1458
1459 let crate_name = "bench-mobile";
1461 let bundle_prefix = "dev.world.benchmobile";
1462 let project_pascal = "BenchRunner";
1463
1464 let result = generate_ios_project(
1465 &temp_dir,
1466 crate_name,
1467 project_pascal,
1468 bundle_prefix,
1469 "bench_mobile::test_func",
1470 );
1471 assert!(
1472 result.is_ok(),
1473 "generate_ios_project failed: {:?}",
1474 result.err()
1475 );
1476
1477 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1479 assert!(project_yml_path.exists(), "project.yml should exist");
1480
1481 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1483
1484 assert!(
1487 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1488 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1489 project_yml
1490 );
1491 assert!(
1492 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1493 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1494 project_yml
1495 );
1496 assert!(
1497 project_yml.contains("embed: false"),
1498 "Static xcframework dependency should be link-only, got:\n{}",
1499 project_yml
1500 );
1501
1502 fs::remove_dir_all(&temp_dir).ok();
1504 }
1505
1506 #[test]
1507 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1508 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1509 let _ = fs::remove_dir_all(&temp_dir);
1510 fs::create_dir_all(&temp_dir).unwrap();
1511
1512 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1513 .expect("initial iOS project generation should succeed");
1514
1515 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1516 assert!(content_view_path.exists(), "ContentView.swift should exist");
1517
1518 fs::write(&content_view_path, "stale generated content").unwrap();
1519
1520 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1521 .expect("refreshing existing iOS project should succeed");
1522
1523 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1524 assert!(
1525 refreshed.contains("ProfileLaunchOptions"),
1526 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1527 refreshed
1528 );
1529 assert!(
1530 refreshed.contains("repeatUntilMs"),
1531 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1532 refreshed
1533 );
1534
1535 fs::remove_dir_all(&temp_dir).ok();
1536 }
1537
1538 #[test]
1539 fn test_cross_platform_naming_consistency() {
1540 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1542 let _ = fs::remove_dir_all(&temp_dir);
1543 fs::create_dir_all(&temp_dir).unwrap();
1544
1545 let project_name = "bench-mobile";
1546
1547 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1549 assert!(
1550 result.is_ok(),
1551 "generate_android_project failed: {:?}",
1552 result.err()
1553 );
1554
1555 let bundle_id_component = sanitize_bundle_id_component(project_name);
1557 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1558 let result = generate_ios_project(
1559 &temp_dir,
1560 &project_name.replace('-', "_"),
1561 "BenchRunner",
1562 &bundle_prefix,
1563 "bench_mobile::test_func",
1564 );
1565 assert!(
1566 result.is_ok(),
1567 "generate_ios_project failed: {:?}",
1568 result.err()
1569 );
1570
1571 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1573 .expect("Failed to read Android build.gradle");
1574
1575 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1577 .expect("Failed to read iOS project.yml");
1578
1579 assert!(
1583 android_build_gradle.contains("dev.world.benchmobile"),
1584 "Android package should be 'dev.world.benchmobile', got:\n{}",
1585 android_build_gradle
1586 );
1587 assert!(
1588 ios_project_yml.contains("dev.world.benchmobile"),
1589 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1590 ios_project_yml
1591 );
1592
1593 assert!(
1595 !android_build_gradle.contains("dev.world.bench-mobile"),
1596 "Android package should NOT contain hyphens"
1597 );
1598 assert!(
1599 !android_build_gradle.contains("dev.world.bench_mobile"),
1600 "Android package should NOT contain underscores"
1601 );
1602
1603 fs::remove_dir_all(&temp_dir).ok();
1605 }
1606
1607 #[test]
1608 fn test_cross_platform_version_consistency() {
1609 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1611 let _ = fs::remove_dir_all(&temp_dir);
1612 fs::create_dir_all(&temp_dir).unwrap();
1613
1614 let project_name = "test-project";
1615
1616 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1618 assert!(
1619 result.is_ok(),
1620 "generate_android_project failed: {:?}",
1621 result.err()
1622 );
1623
1624 let bundle_id_component = sanitize_bundle_id_component(project_name);
1626 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1627 let result = generate_ios_project(
1628 &temp_dir,
1629 &project_name.replace('-', "_"),
1630 "BenchRunner",
1631 &bundle_prefix,
1632 "test_project::test_func",
1633 );
1634 assert!(
1635 result.is_ok(),
1636 "generate_ios_project failed: {:?}",
1637 result.err()
1638 );
1639
1640 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1642 .expect("Failed to read Android build.gradle");
1643
1644 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1646 .expect("Failed to read iOS project.yml");
1647
1648 assert!(
1650 android_build_gradle.contains("versionName \"1.0.0\""),
1651 "Android versionName should be '1.0.0', got:\n{}",
1652 android_build_gradle
1653 );
1654 assert!(
1655 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1656 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1657 ios_project_yml
1658 );
1659
1660 fs::remove_dir_all(&temp_dir).ok();
1662 }
1663
1664 #[test]
1665 fn test_bundle_id_prefix_consistency() {
1666 let test_cases = vec![
1668 ("my-project", "dev.world.myproject"),
1669 ("bench_mobile", "dev.world.benchmobile"),
1670 ("TestApp", "dev.world.testapp"),
1671 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1672 (
1673 "app_with_many_underscores",
1674 "dev.world.appwithmanyunderscores",
1675 ),
1676 ];
1677
1678 for (input, expected_prefix) in test_cases {
1679 let sanitized = sanitize_bundle_id_component(input);
1680 let full_prefix = format!("dev.world.{}", sanitized);
1681 assert_eq!(
1682 full_prefix, expected_prefix,
1683 "For input '{}', expected '{}' but got '{}'",
1684 input, expected_prefix, full_prefix
1685 );
1686 }
1687 }
1688}