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 HarnessTimelineSpan {
178 pub phase: String,
179 pub start_offset_ns: u64,
180 pub end_offset_ns: u64,
181 pub iteration: Option<u32>,
182}
183
184#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
185pub struct BenchReport {
186 pub spec: BenchSpec,
187 pub samples: Vec<BenchSample>,
188 pub phases: Vec<SemanticPhase>,
189 pub timeline: Vec<HarnessTimelineSpan>,
190}
191
192#[derive(Debug, thiserror::Error, uniffi::Error)]
193#[uniffi(flat_error)]
194pub enum BenchError {
195 #[error("iterations must be greater than zero")]
196 InvalidIterations,
197
198 #[error("unknown benchmark function: {name}")]
199 UnknownFunction { name: String },
200
201 #[error("benchmark execution failed: {reason}")]
202 ExecutionFailed { reason: String },
203}
204
205// Convert from mobench-sdk types
206impl From<mobench_sdk::BenchSpec> for BenchSpec {
207 fn from(spec: mobench_sdk::BenchSpec) -> Self {
208 Self {
209 name: spec.name,
210 iterations: spec.iterations,
211 warmup: spec.warmup,
212 }
213 }
214}
215
216impl From<BenchSpec> for mobench_sdk::BenchSpec {
217 fn from(spec: BenchSpec) -> Self {
218 Self {
219 name: spec.name,
220 iterations: spec.iterations,
221 warmup: spec.warmup,
222 }
223 }
224}
225
226impl From<mobench_sdk::BenchSample> for BenchSample {
227 fn from(sample: mobench_sdk::BenchSample) -> Self {
228 Self {
229 duration_ns: sample.duration_ns,
230 }
231 }
232}
233
234impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
235 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
236 Self {
237 name: phase.name,
238 duration_ns: phase.duration_ns,
239 }
240 }
241}
242
243impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
244 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
245 Self {
246 phase: span.phase,
247 start_offset_ns: span.start_offset_ns,
248 end_offset_ns: span.end_offset_ns,
249 iteration: span.iteration,
250 }
251 }
252}
253
254impl From<mobench_sdk::RunnerReport> for BenchReport {
255 fn from(report: mobench_sdk::RunnerReport) -> Self {
256 Self {
257 spec: report.spec.into(),
258 samples: report.samples.into_iter().map(Into::into).collect(),
259 phases: report.phases.into_iter().map(Into::into).collect(),
260 timeline: report.timeline.into_iter().map(Into::into).collect(),
261 }
262 }
263}
264
265impl From<mobench_sdk::BenchError> for BenchError {
266 fn from(err: mobench_sdk::BenchError) -> Self {
267 match err {
268 mobench_sdk::BenchError::Runner(runner_err) => {
269 BenchError::ExecutionFailed {
270 reason: runner_err.to_string(),
271 }
272 }
273 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
274 BenchError::UnknownFunction { name }
275 }
276 _ => BenchError::ExecutionFailed {
277 reason: err.to_string(),
278 },
279 }
280 }
281}
282
283/// Runs a benchmark by name with the given specification
284///
285/// This is the main FFI entry point called from mobile platforms.
286#[uniffi::export]
287pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
288 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
289 let report = mobench_sdk::run_benchmark(sdk_spec)?;
290 Ok(report.into())
291}
292
293// Generate UniFFI scaffolding
294uniffi::setup_scaffolding!();
295"#;
296
297 let lib_rs = render_template(
298 lib_rs_template,
299 &[TemplateVar {
300 name: "USER_CRATE",
301 value: project_name.replace('-', "_"),
302 }],
303 );
304 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
305
306 let build_rs = r#"fn main() {
308 uniffi::generate_scaffolding("src/lib.rs").unwrap();
309}
310"#;
311
312 fs::write(crate_dir.join("build.rs"), build_rs)?;
313
314 let bin_dir = crate_dir.join("src/bin");
316 fs::create_dir_all(&bin_dir)?;
317 let uniffi_bindgen_rs = r#"fn main() {
318 uniffi::uniffi_bindgen_main()
319}
320"#;
321 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
322
323 Ok(())
324}
325
326pub fn generate_android_project(
337 output_dir: &Path,
338 project_slug: &str,
339 default_function: &str,
340) -> Result<(), BenchError> {
341 let target_dir = output_dir.join("android");
342 reset_generated_project_dir(&target_dir)?;
343 let library_name = project_slug.replace('-', "_");
344 let project_pascal = to_pascal_case(project_slug);
345 let package_id_component = sanitize_bundle_id_component(project_slug);
348 let package_name = format!("dev.world.{}", package_id_component);
349 let vars = vec![
350 TemplateVar {
351 name: "PROJECT_NAME",
352 value: project_slug.to_string(),
353 },
354 TemplateVar {
355 name: "PROJECT_NAME_PASCAL",
356 value: project_pascal.clone(),
357 },
358 TemplateVar {
359 name: "APP_NAME",
360 value: format!("{} Benchmark", project_pascal),
361 },
362 TemplateVar {
363 name: "PACKAGE_NAME",
364 value: package_name.clone(),
365 },
366 TemplateVar {
367 name: "UNIFFI_NAMESPACE",
368 value: library_name.clone(),
369 },
370 TemplateVar {
371 name: "LIBRARY_NAME",
372 value: library_name,
373 },
374 TemplateVar {
375 name: "DEFAULT_FUNCTION",
376 value: default_function.to_string(),
377 },
378 ];
379 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
380
381 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
384
385 Ok(())
386}
387
388fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
389 if target_dir.exists() {
390 fs::remove_dir_all(target_dir).map_err(|e| {
391 BenchError::Build(format!(
392 "Failed to clear existing generated project at {:?}: {}",
393 target_dir, e
394 ))
395 })?;
396 }
397 Ok(())
398}
399
400fn move_kotlin_files_to_package_dir(
410 android_dir: &Path,
411 package_name: &str,
412) -> Result<(), BenchError> {
413 let package_path = package_name.replace('.', "/");
415
416 let main_java_dir = android_dir.join("app/src/main/java");
418 let main_package_dir = main_java_dir.join(&package_path);
419 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
420
421 let test_java_dir = android_dir.join("app/src/androidTest/java");
423 let test_package_dir = test_java_dir.join(&package_path);
424 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
425
426 Ok(())
427}
428
429fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
431 let src_file = src_dir.join(filename);
432 if !src_file.exists() {
433 return Ok(());
435 }
436
437 fs::create_dir_all(dest_dir).map_err(|e| {
439 BenchError::Build(format!(
440 "Failed to create package directory {:?}: {}",
441 dest_dir, e
442 ))
443 })?;
444
445 let dest_file = dest_dir.join(filename);
446
447 fs::copy(&src_file, &dest_file).map_err(|e| {
449 BenchError::Build(format!(
450 "Failed to copy {} to {:?}: {}",
451 filename, dest_file, e
452 ))
453 })?;
454
455 fs::remove_file(&src_file).map_err(|e| {
456 BenchError::Build(format!(
457 "Failed to remove original file {:?}: {}",
458 src_file, e
459 ))
460 })?;
461
462 Ok(())
463}
464
465pub fn generate_ios_project(
478 output_dir: &Path,
479 project_slug: &str,
480 project_pascal: &str,
481 bundle_prefix: &str,
482 default_function: &str,
483) -> Result<(), BenchError> {
484 let target_dir = output_dir.join("ios");
485 reset_generated_project_dir(&target_dir)?;
486 let sanitized_bundle_prefix = {
489 let parts: Vec<&str> = bundle_prefix.split('.').collect();
490 parts
491 .iter()
492 .map(|part| sanitize_bundle_id_component(part))
493 .collect::<Vec<_>>()
494 .join(".")
495 };
496 let vars = vec![
500 TemplateVar {
501 name: "DEFAULT_FUNCTION",
502 value: default_function.to_string(),
503 },
504 TemplateVar {
505 name: "PROJECT_NAME_PASCAL",
506 value: project_pascal.to_string(),
507 },
508 TemplateVar {
509 name: "BUNDLE_ID_PREFIX",
510 value: sanitized_bundle_prefix.clone(),
511 },
512 TemplateVar {
513 name: "BUNDLE_ID",
514 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
515 },
516 TemplateVar {
517 name: "LIBRARY_NAME",
518 value: project_slug.replace('-', "_"),
519 },
520 ];
521 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
522 Ok(())
523}
524
525fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
527 let config_target = match config.target {
528 Target::Ios => "ios",
529 Target::Android | Target::Both => "android",
530 };
531 let config_content = format!(
532 r#"# mobench configuration
533# This file controls how benchmarks are executed on devices.
534
535target = "{}"
536function = "example_fibonacci"
537iterations = 100
538warmup = 10
539device_matrix = "device-matrix.yaml"
540device_tags = ["default"]
541
542[browserstack]
543app_automate_username = "${{BROWSERSTACK_USERNAME}}"
544app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
545project = "{}-benchmarks"
546
547[ios_xcuitest]
548app = "target/ios/BenchRunner.ipa"
549test_suite = "target/ios/BenchRunnerUITests.zip"
550"#,
551 config_target, config.project_name
552 );
553
554 fs::write(output_dir.join("bench-config.toml"), config_content)?;
555
556 Ok(())
557}
558
559fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
561 let examples_dir = output_dir.join("benches");
562 fs::create_dir_all(&examples_dir)?;
563
564 let example_content = r#"//! Example benchmarks
565//!
566//! This file demonstrates how to write benchmarks with mobench-sdk.
567
568use mobench_sdk::benchmark;
569
570/// Simple benchmark example
571#[benchmark]
572fn example_fibonacci() {
573 let result = fibonacci(30);
574 std::hint::black_box(result);
575}
576
577/// Another example with a loop
578#[benchmark]
579fn example_sum() {
580 let mut sum = 0u64;
581 for i in 0..10000 {
582 sum = sum.wrapping_add(i);
583 }
584 std::hint::black_box(sum);
585}
586
587// Helper function (not benchmarked)
588fn fibonacci(n: u32) -> u64 {
589 match n {
590 0 => 0,
591 1 => 1,
592 _ => {
593 let mut a = 0u64;
594 let mut b = 1u64;
595 for _ in 2..=n {
596 let next = a.wrapping_add(b);
597 a = b;
598 b = next;
599 }
600 b
601 }
602 }
603}
604"#;
605
606 fs::write(examples_dir.join("example.rs"), example_content)?;
607
608 Ok(())
609}
610
611const TEMPLATE_EXTENSIONS: &[&str] = &[
613 "gradle",
614 "xml",
615 "kt",
616 "java",
617 "swift",
618 "yml",
619 "yaml",
620 "json",
621 "toml",
622 "md",
623 "txt",
624 "h",
625 "m",
626 "plist",
627 "pbxproj",
628 "xcscheme",
629 "xcworkspacedata",
630 "entitlements",
631 "modulemap",
632];
633
634fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
635 for entry in dir.entries() {
636 match entry {
637 DirEntry::Dir(sub) => {
638 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
640 continue;
641 }
642 render_dir(sub, out_root, vars)?;
643 }
644 DirEntry::File(file) => {
645 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
646 continue;
647 }
648 let mut relative = file.path().to_path_buf();
650 let mut contents = file.contents().to_vec();
651
652 let is_explicit_template = relative
654 .extension()
655 .map(|ext| ext == "template")
656 .unwrap_or(false);
657
658 let should_render = is_explicit_template || is_template_file(&relative);
660
661 if is_explicit_template {
662 relative.set_extension("");
664 }
665
666 if should_render {
667 if let Ok(text) = std::str::from_utf8(&contents) {
668 let rendered = render_template(text, vars);
669 validate_no_unreplaced_placeholders(&rendered, &relative)?;
671 contents = rendered.into_bytes();
672 }
673 }
674
675 let out_path = out_root.join(relative);
676 if let Some(parent) = out_path.parent() {
677 fs::create_dir_all(parent)?;
678 }
679 fs::write(&out_path, contents)?;
680 }
681 }
682 }
683 Ok(())
684}
685
686fn is_template_file(path: &Path) -> bool {
689 if let Some(ext) = path.extension() {
691 if ext == "template" {
692 return true;
693 }
694 if let Some(ext_str) = ext.to_str() {
696 return TEMPLATE_EXTENSIONS.contains(&ext_str);
697 }
698 }
699 if let Some(stem) = path.file_stem() {
701 let stem_path = Path::new(stem);
702 if let Some(ext) = stem_path.extension() {
703 if let Some(ext_str) = ext.to_str() {
704 return TEMPLATE_EXTENSIONS.contains(&ext_str);
705 }
706 }
707 }
708 false
709}
710
711fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
713 let mut pos = 0;
715 let mut unreplaced = Vec::new();
716
717 while let Some(start) = content[pos..].find("{{") {
718 let abs_start = pos + start;
719 if let Some(end) = content[abs_start..].find("}}") {
720 let placeholder = &content[abs_start..abs_start + end + 2];
721 let var_name = &content[abs_start + 2..abs_start + end];
723 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
726 unreplaced.push(placeholder.to_string());
727 }
728 pos = abs_start + end + 2;
729 } else {
730 break;
731 }
732 }
733
734 if !unreplaced.is_empty() {
735 return Err(BenchError::Build(format!(
736 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
737 This is a bug in mobench-sdk. Please report it at:\n\
738 https://github.com/worldcoin/mobile-bench-rs/issues",
739 file_path, unreplaced
740 )));
741 }
742
743 Ok(())
744}
745
746fn render_template(input: &str, vars: &[TemplateVar]) -> String {
747 let mut output = input.to_string();
748 for var in vars {
749 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
750 }
751 output
752}
753
754pub fn sanitize_bundle_id_component(name: &str) -> String {
765 name.chars()
766 .filter(|c| c.is_ascii_alphanumeric())
767 .collect::<String>()
768 .to_lowercase()
769}
770
771fn sanitize_package_name(name: &str) -> String {
772 name.chars()
773 .map(|c| {
774 if c.is_ascii_alphanumeric() {
775 c.to_ascii_lowercase()
776 } else {
777 '-'
778 }
779 })
780 .collect::<String>()
781 .trim_matches('-')
782 .replace("--", "-")
783}
784
785pub fn to_pascal_case(input: &str) -> String {
787 input
788 .split(|c: char| !c.is_ascii_alphanumeric())
789 .filter(|s| !s.is_empty())
790 .map(|s| {
791 let mut chars = s.chars();
792 let first = chars.next().unwrap().to_ascii_uppercase();
793 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
794 format!("{}{}", first, rest)
795 })
796 .collect::<String>()
797}
798
799pub fn android_project_exists(output_dir: &Path) -> bool {
803 let android_dir = output_dir.join("android");
804 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
805}
806
807pub fn ios_project_exists(output_dir: &Path) -> bool {
811 output_dir.join("ios/BenchRunner/project.yml").exists()
812}
813
814fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
819 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
820 let Ok(content) = std::fs::read_to_string(&project_yml) else {
821 return false;
822 };
823 let expected = format!("../{}.xcframework", library_name);
824 content.contains(&expected)
825}
826
827fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
832 let build_gradle = output_dir.join("android/app/build.gradle");
833 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
834 return false;
835 };
836 let expected = format!("lib{}.so", library_name);
837 content.contains(&expected)
838}
839
840pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
855 let lib_rs = crate_dir.join("src/lib.rs");
856 if !lib_rs.exists() {
857 return None;
858 }
859
860 let file = fs::File::open(&lib_rs).ok()?;
861 let reader = BufReader::new(file);
862
863 let mut found_benchmark_attr = false;
864 let crate_name_normalized = crate_name.replace('-', "_");
865
866 for line in reader.lines().map_while(Result::ok) {
867 let trimmed = line.trim();
868
869 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
871 found_benchmark_attr = true;
872 continue;
873 }
874
875 if found_benchmark_attr {
877 if let Some(fn_pos) = trimmed.find("fn ") {
879 let after_fn = &trimmed[fn_pos + 3..];
880 let fn_name: String = after_fn
882 .chars()
883 .take_while(|c| c.is_alphanumeric() || *c == '_')
884 .collect();
885
886 if !fn_name.is_empty() {
887 return Some(format!("{}::{}", crate_name_normalized, fn_name));
888 }
889 }
890 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
893 found_benchmark_attr = false;
894 }
895 }
896 }
897
898 None
899}
900
901pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
915 let lib_rs = crate_dir.join("src/lib.rs");
916 if !lib_rs.exists() {
917 return Vec::new();
918 }
919
920 let Ok(file) = fs::File::open(&lib_rs) else {
921 return Vec::new();
922 };
923 let reader = BufReader::new(file);
924
925 let mut benchmarks = Vec::new();
926 let mut found_benchmark_attr = false;
927 let crate_name_normalized = crate_name.replace('-', "_");
928
929 for line in reader.lines().map_while(Result::ok) {
930 let trimmed = line.trim();
931
932 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
934 found_benchmark_attr = true;
935 continue;
936 }
937
938 if found_benchmark_attr {
940 if let Some(fn_pos) = trimmed.find("fn ") {
942 let after_fn = &trimmed[fn_pos + 3..];
943 let fn_name: String = after_fn
945 .chars()
946 .take_while(|c| c.is_alphanumeric() || *c == '_')
947 .collect();
948
949 if !fn_name.is_empty() {
950 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
951 }
952 found_benchmark_attr = false;
953 }
954 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
957 found_benchmark_attr = false;
958 }
959 }
960 }
961
962 benchmarks
963}
964
965pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
977 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
978 let crate_name_normalized = crate_name.replace('-', "_");
979
980 let normalized_name = if function_name.contains("::") {
982 function_name.to_string()
983 } else {
984 format!("{}::{}", crate_name_normalized, function_name)
985 };
986
987 benchmarks.iter().any(|b| b == &normalized_name)
988}
989
990pub fn resolve_default_function(
1005 project_root: &Path,
1006 crate_name: &str,
1007 crate_dir: Option<&Path>,
1008) -> String {
1009 let crate_name_normalized = crate_name.replace('-', "_");
1010
1011 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1013 vec![dir.to_path_buf()]
1014 } else {
1015 vec![
1016 project_root.join("bench-mobile"),
1017 project_root.join("crates").join(crate_name),
1018 project_root.to_path_buf(),
1019 ]
1020 };
1021
1022 for dir in &search_dirs {
1024 if dir.join("Cargo.toml").exists() {
1025 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
1026 return detected;
1027 }
1028 }
1029 }
1030
1031 format!("{}::example_benchmark", crate_name_normalized)
1033}
1034
1035pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1046 ensure_android_project_with_options(output_dir, crate_name, None, None)
1047}
1048
1049pub fn ensure_android_project_with_options(
1061 output_dir: &Path,
1062 crate_name: &str,
1063 project_root: Option<&Path>,
1064 crate_dir: Option<&Path>,
1065) -> Result<(), BenchError> {
1066 let library_name = crate_name.replace('-', "_");
1067 if android_project_exists(output_dir)
1068 && android_project_matches_library(output_dir, &library_name)
1069 {
1070 return Ok(());
1071 }
1072
1073 println!("Android project not found, generating scaffolding...");
1074 let project_slug = crate_name.replace('-', "_");
1075
1076 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1078 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1079
1080 generate_android_project(output_dir, &project_slug, &default_function)?;
1081 println!(
1082 " Generated Android project at {:?}",
1083 output_dir.join("android")
1084 );
1085 println!(" Default benchmark function: {}", default_function);
1086 Ok(())
1087}
1088
1089pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1100 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1101}
1102
1103pub fn ensure_ios_project_with_options(
1115 output_dir: &Path,
1116 crate_name: &str,
1117 project_root: Option<&Path>,
1118 crate_dir: Option<&Path>,
1119) -> Result<(), BenchError> {
1120 let library_name = crate_name.replace('-', "_");
1121 let project_exists = ios_project_exists(output_dir);
1122 let project_matches = ios_project_matches_library(output_dir, &library_name);
1123 if project_exists && !project_matches {
1124 println!("Existing iOS scaffolding does not match library, regenerating...");
1125 } else if project_exists {
1126 println!("Refreshing generated iOS scaffolding...");
1127 } else {
1128 println!("iOS project not found, generating scaffolding...");
1129 }
1130
1131 let project_pascal = "BenchRunner";
1133 let library_name = crate_name.replace('-', "_");
1135 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1138 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1139
1140 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1142 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1143
1144 generate_ios_project(
1145 output_dir,
1146 &library_name,
1147 project_pascal,
1148 &bundle_prefix,
1149 &default_function,
1150 )?;
1151 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1152 println!(" Default benchmark function: {}", default_function);
1153 Ok(())
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159 use std::env;
1160
1161 #[test]
1162 fn test_generate_bench_mobile_crate() {
1163 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1164 fs::create_dir_all(&temp_dir).unwrap();
1165
1166 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1167 assert!(result.is_ok());
1168
1169 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1171 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1172 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1173
1174 fs::remove_dir_all(&temp_dir).ok();
1176 }
1177
1178 #[test]
1179 fn test_generate_android_project_no_unreplaced_placeholders() {
1180 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1181 let _ = fs::remove_dir_all(&temp_dir);
1183 fs::create_dir_all(&temp_dir).unwrap();
1184
1185 let result =
1186 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1187 assert!(
1188 result.is_ok(),
1189 "generate_android_project failed: {:?}",
1190 result.err()
1191 );
1192
1193 let android_dir = temp_dir.join("android");
1195 assert!(android_dir.join("settings.gradle").exists());
1196 assert!(android_dir.join("app/build.gradle").exists());
1197 assert!(
1198 android_dir
1199 .join("app/src/main/AndroidManifest.xml")
1200 .exists()
1201 );
1202 assert!(
1203 android_dir
1204 .join("app/src/main/res/values/strings.xml")
1205 .exists()
1206 );
1207 assert!(
1208 android_dir
1209 .join("app/src/main/res/values/themes.xml")
1210 .exists()
1211 );
1212
1213 let files_to_check = [
1215 "settings.gradle",
1216 "app/build.gradle",
1217 "app/src/main/AndroidManifest.xml",
1218 "app/src/main/res/values/strings.xml",
1219 "app/src/main/res/values/themes.xml",
1220 ];
1221
1222 for file in files_to_check {
1223 let path = android_dir.join(file);
1224 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1225
1226 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1228 assert!(
1229 !has_placeholder,
1230 "File {} contains unreplaced template placeholders: {}",
1231 file, contents
1232 );
1233 }
1234
1235 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1237 assert!(
1238 settings.contains("my-bench-project-android")
1239 || settings.contains("my_bench_project-android"),
1240 "settings.gradle should contain project name"
1241 );
1242
1243 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1244 assert!(
1246 build_gradle.contains("dev.world.mybenchproject"),
1247 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1248 );
1249
1250 let manifest =
1251 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1252 assert!(
1253 manifest.contains("Theme.MyBenchProject"),
1254 "AndroidManifest.xml should contain PascalCase theme name"
1255 );
1256
1257 let strings =
1258 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1259 assert!(
1260 strings.contains("Benchmark"),
1261 "strings.xml should contain app name with Benchmark"
1262 );
1263
1264 let main_activity_path =
1267 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1268 assert!(
1269 main_activity_path.exists(),
1270 "MainActivity.kt should be in package directory: {:?}",
1271 main_activity_path
1272 );
1273
1274 let test_activity_path = android_dir
1275 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1276 assert!(
1277 test_activity_path.exists(),
1278 "MainActivityTest.kt should be in package directory: {:?}",
1279 test_activity_path
1280 );
1281
1282 assert!(
1284 !android_dir
1285 .join("app/src/main/java/MainActivity.kt")
1286 .exists(),
1287 "MainActivity.kt should not be in root java directory"
1288 );
1289 assert!(
1290 !android_dir
1291 .join("app/src/androidTest/java/MainActivityTest.kt")
1292 .exists(),
1293 "MainActivityTest.kt should not be in root java directory"
1294 );
1295
1296 fs::remove_dir_all(&temp_dir).ok();
1298 }
1299
1300 #[test]
1301 fn test_generate_android_project_replaces_previous_package_tree() {
1302 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1303 let _ = fs::remove_dir_all(&temp_dir);
1304 fs::create_dir_all(&temp_dir).unwrap();
1305
1306 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1307 .unwrap();
1308 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1309 assert!(
1310 old_package_dir.exists(),
1311 "expected first package tree to exist"
1312 );
1313
1314 generate_android_project(
1315 &temp_dir,
1316 "basic_benchmark",
1317 "basic_benchmark::bench_fibonacci",
1318 )
1319 .unwrap();
1320
1321 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1322 assert!(
1323 new_package_dir.exists(),
1324 "expected new package tree to exist"
1325 );
1326 assert!(
1327 !old_package_dir.exists(),
1328 "old package tree should be removed when regenerating the Android scaffold"
1329 );
1330
1331 fs::remove_dir_all(&temp_dir).ok();
1332 }
1333
1334 #[test]
1335 fn test_is_template_file() {
1336 assert!(is_template_file(Path::new("settings.gradle")));
1337 assert!(is_template_file(Path::new("app/build.gradle")));
1338 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1339 assert!(is_template_file(Path::new("strings.xml")));
1340 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1341 assert!(is_template_file(Path::new("project.yml")));
1342 assert!(is_template_file(Path::new("Info.plist")));
1343 assert!(!is_template_file(Path::new("libfoo.so")));
1344 assert!(!is_template_file(Path::new("image.png")));
1345 }
1346
1347 #[test]
1348 fn test_validate_no_unreplaced_placeholders() {
1349 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1351
1352 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1354
1355 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1357 assert!(result.is_err());
1358 let err = result.unwrap_err().to_string();
1359 assert!(err.contains("{{NAME}}"));
1360 }
1361
1362 #[test]
1363 fn test_to_pascal_case() {
1364 assert_eq!(to_pascal_case("my-project"), "MyProject");
1365 assert_eq!(to_pascal_case("my_project"), "MyProject");
1366 assert_eq!(to_pascal_case("myproject"), "Myproject");
1367 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1368 }
1369
1370 #[test]
1371 fn test_detect_default_function_finds_benchmark() {
1372 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1373 let _ = fs::remove_dir_all(&temp_dir);
1374 fs::create_dir_all(temp_dir.join("src")).unwrap();
1375
1376 let lib_content = r#"
1378use mobench_sdk::benchmark;
1379
1380/// Some docs
1381#[benchmark]
1382fn my_benchmark_func() {
1383 // benchmark code
1384}
1385
1386fn helper_func() {}
1387"#;
1388 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1389 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1390
1391 let result = detect_default_function(&temp_dir, "my_crate");
1392 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1393
1394 fs::remove_dir_all(&temp_dir).ok();
1396 }
1397
1398 #[test]
1399 fn test_detect_default_function_no_benchmark() {
1400 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1401 let _ = fs::remove_dir_all(&temp_dir);
1402 fs::create_dir_all(temp_dir.join("src")).unwrap();
1403
1404 let lib_content = r#"
1406fn regular_function() {
1407 // no benchmark here
1408}
1409"#;
1410 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1411
1412 let result = detect_default_function(&temp_dir, "my_crate");
1413 assert!(result.is_none());
1414
1415 fs::remove_dir_all(&temp_dir).ok();
1417 }
1418
1419 #[test]
1420 fn test_detect_default_function_pub_fn() {
1421 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1422 let _ = fs::remove_dir_all(&temp_dir);
1423 fs::create_dir_all(temp_dir.join("src")).unwrap();
1424
1425 let lib_content = r#"
1427#[benchmark]
1428pub fn public_bench() {
1429 // benchmark code
1430}
1431"#;
1432 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1433
1434 let result = detect_default_function(&temp_dir, "test-crate");
1435 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1436
1437 fs::remove_dir_all(&temp_dir).ok();
1439 }
1440
1441 #[test]
1442 fn test_resolve_default_function_fallback() {
1443 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1444 let _ = fs::remove_dir_all(&temp_dir);
1445 fs::create_dir_all(&temp_dir).unwrap();
1446
1447 let result = resolve_default_function(&temp_dir, "my-crate", None);
1449 assert_eq!(result, "my_crate::example_benchmark");
1450
1451 fs::remove_dir_all(&temp_dir).ok();
1453 }
1454
1455 #[test]
1456 fn test_sanitize_bundle_id_component() {
1457 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1459 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1461 assert_eq!(
1463 sanitize_bundle_id_component("my-project_name"),
1464 "myprojectname"
1465 );
1466 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1468 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1470 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1472 assert_eq!(
1474 sanitize_bundle_id_component("My-Complex_Project-123"),
1475 "mycomplexproject123"
1476 );
1477 }
1478
1479 #[test]
1480 fn test_generate_ios_project_bundle_id_not_duplicated() {
1481 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1482 let _ = fs::remove_dir_all(&temp_dir);
1484 fs::create_dir_all(&temp_dir).unwrap();
1485
1486 let crate_name = "bench-mobile";
1488 let bundle_prefix = "dev.world.benchmobile";
1489 let project_pascal = "BenchRunner";
1490
1491 let result = generate_ios_project(
1492 &temp_dir,
1493 crate_name,
1494 project_pascal,
1495 bundle_prefix,
1496 "bench_mobile::test_func",
1497 );
1498 assert!(
1499 result.is_ok(),
1500 "generate_ios_project failed: {:?}",
1501 result.err()
1502 );
1503
1504 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1506 assert!(project_yml_path.exists(), "project.yml should exist");
1507
1508 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1510
1511 assert!(
1514 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1515 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1516 project_yml
1517 );
1518 assert!(
1519 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1520 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1521 project_yml
1522 );
1523 assert!(
1524 project_yml.contains("embed: false"),
1525 "Static xcframework dependency should be link-only, got:\n{}",
1526 project_yml
1527 );
1528
1529 fs::remove_dir_all(&temp_dir).ok();
1531 }
1532
1533 #[test]
1534 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1535 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1536 let _ = fs::remove_dir_all(&temp_dir);
1537 fs::create_dir_all(&temp_dir).unwrap();
1538
1539 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1540 .expect("initial iOS project generation should succeed");
1541
1542 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1543 assert!(content_view_path.exists(), "ContentView.swift should exist");
1544
1545 fs::write(&content_view_path, "stale generated content").unwrap();
1546
1547 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1548 .expect("refreshing existing iOS project should succeed");
1549
1550 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1551 assert!(
1552 refreshed.contains("ProfileLaunchOptions"),
1553 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1554 refreshed
1555 );
1556 assert!(
1557 refreshed.contains("repeatUntilMs"),
1558 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1559 refreshed
1560 );
1561
1562 fs::remove_dir_all(&temp_dir).ok();
1563 }
1564
1565 #[test]
1566 fn test_cross_platform_naming_consistency() {
1567 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1569 let _ = fs::remove_dir_all(&temp_dir);
1570 fs::create_dir_all(&temp_dir).unwrap();
1571
1572 let project_name = "bench-mobile";
1573
1574 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1576 assert!(
1577 result.is_ok(),
1578 "generate_android_project failed: {:?}",
1579 result.err()
1580 );
1581
1582 let bundle_id_component = sanitize_bundle_id_component(project_name);
1584 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1585 let result = generate_ios_project(
1586 &temp_dir,
1587 &project_name.replace('-', "_"),
1588 "BenchRunner",
1589 &bundle_prefix,
1590 "bench_mobile::test_func",
1591 );
1592 assert!(
1593 result.is_ok(),
1594 "generate_ios_project failed: {:?}",
1595 result.err()
1596 );
1597
1598 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1600 .expect("Failed to read Android build.gradle");
1601
1602 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1604 .expect("Failed to read iOS project.yml");
1605
1606 assert!(
1610 android_build_gradle.contains("dev.world.benchmobile"),
1611 "Android package should be 'dev.world.benchmobile', got:\n{}",
1612 android_build_gradle
1613 );
1614 assert!(
1615 ios_project_yml.contains("dev.world.benchmobile"),
1616 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1617 ios_project_yml
1618 );
1619
1620 assert!(
1622 !android_build_gradle.contains("dev.world.bench-mobile"),
1623 "Android package should NOT contain hyphens"
1624 );
1625 assert!(
1626 !android_build_gradle.contains("dev.world.bench_mobile"),
1627 "Android package should NOT contain underscores"
1628 );
1629
1630 fs::remove_dir_all(&temp_dir).ok();
1632 }
1633
1634 #[test]
1635 fn test_cross_platform_version_consistency() {
1636 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1638 let _ = fs::remove_dir_all(&temp_dir);
1639 fs::create_dir_all(&temp_dir).unwrap();
1640
1641 let project_name = "test-project";
1642
1643 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1645 assert!(
1646 result.is_ok(),
1647 "generate_android_project failed: {:?}",
1648 result.err()
1649 );
1650
1651 let bundle_id_component = sanitize_bundle_id_component(project_name);
1653 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1654 let result = generate_ios_project(
1655 &temp_dir,
1656 &project_name.replace('-', "_"),
1657 "BenchRunner",
1658 &bundle_prefix,
1659 "test_project::test_func",
1660 );
1661 assert!(
1662 result.is_ok(),
1663 "generate_ios_project failed: {:?}",
1664 result.err()
1665 );
1666
1667 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1669 .expect("Failed to read Android build.gradle");
1670
1671 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1673 .expect("Failed to read iOS project.yml");
1674
1675 assert!(
1677 android_build_gradle.contains("versionName \"1.0.0\""),
1678 "Android versionName should be '1.0.0', got:\n{}",
1679 android_build_gradle
1680 );
1681 assert!(
1682 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1683 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1684 ios_project_yml
1685 );
1686
1687 fs::remove_dir_all(&temp_dir).ok();
1689 }
1690
1691 #[test]
1692 fn test_bundle_id_prefix_consistency() {
1693 let test_cases = vec![
1695 ("my-project", "dev.world.myproject"),
1696 ("bench_mobile", "dev.world.benchmobile"),
1697 ("TestApp", "dev.world.testapp"),
1698 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1699 (
1700 "app_with_many_underscores",
1701 "dev.world.appwithmanyunderscores",
1702 ),
1703 ];
1704
1705 for (input, expected_prefix) in test_cases {
1706 let sanitized = sanitize_bundle_id_component(input);
1707 let full_prefix = format!("dev.world.{}", sanitized);
1708 assert_eq!(
1709 full_prefix, expected_prefix,
1710 "For input '{}', expected '{}' but got '{}'",
1711 input, expected_prefix, full_prefix
1712 );
1713 }
1714 }
1715}