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 BenchReport {
172 pub spec: BenchSpec,
173 pub samples: Vec<BenchSample>,
174}
175
176#[derive(Debug, thiserror::Error, uniffi::Error)]
177#[uniffi(flat_error)]
178pub enum BenchError {
179 #[error("iterations must be greater than zero")]
180 InvalidIterations,
181
182 #[error("unknown benchmark function: {name}")]
183 UnknownFunction { name: String },
184
185 #[error("benchmark execution failed: {reason}")]
186 ExecutionFailed { reason: String },
187}
188
189// Convert from mobench-sdk types
190impl From<mobench_sdk::BenchSpec> for BenchSpec {
191 fn from(spec: mobench_sdk::BenchSpec) -> Self {
192 Self {
193 name: spec.name,
194 iterations: spec.iterations,
195 warmup: spec.warmup,
196 }
197 }
198}
199
200impl From<BenchSpec> for mobench_sdk::BenchSpec {
201 fn from(spec: BenchSpec) -> Self {
202 Self {
203 name: spec.name,
204 iterations: spec.iterations,
205 warmup: spec.warmup,
206 }
207 }
208}
209
210impl From<mobench_sdk::BenchSample> for BenchSample {
211 fn from(sample: mobench_sdk::BenchSample) -> Self {
212 Self {
213 duration_ns: sample.duration_ns,
214 }
215 }
216}
217
218impl From<mobench_sdk::RunnerReport> for BenchReport {
219 fn from(report: mobench_sdk::RunnerReport) -> Self {
220 Self {
221 spec: report.spec.into(),
222 samples: report.samples.into_iter().map(Into::into).collect(),
223 }
224 }
225}
226
227impl From<mobench_sdk::BenchError> for BenchError {
228 fn from(err: mobench_sdk::BenchError) -> Self {
229 match err {
230 mobench_sdk::BenchError::Runner(runner_err) => {
231 BenchError::ExecutionFailed {
232 reason: runner_err.to_string(),
233 }
234 }
235 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
236 BenchError::UnknownFunction { name }
237 }
238 _ => BenchError::ExecutionFailed {
239 reason: err.to_string(),
240 },
241 }
242 }
243}
244
245/// Runs a benchmark by name with the given specification
246///
247/// This is the main FFI entry point called from mobile platforms.
248#[uniffi::export]
249pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
250 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
251 let report = mobench_sdk::run_benchmark(sdk_spec)?;
252 Ok(report.into())
253}
254
255// Generate UniFFI scaffolding
256uniffi::setup_scaffolding!();
257"#;
258
259 let lib_rs = render_template(
260 lib_rs_template,
261 &[TemplateVar {
262 name: "USER_CRATE",
263 value: project_name.replace('-', "_"),
264 }],
265 );
266 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
267
268 let build_rs = r#"fn main() {
270 uniffi::generate_scaffolding("src/lib.rs").unwrap();
271}
272"#;
273
274 fs::write(crate_dir.join("build.rs"), build_rs)?;
275
276 let bin_dir = crate_dir.join("src/bin");
278 fs::create_dir_all(&bin_dir)?;
279 let uniffi_bindgen_rs = r#"fn main() {
280 uniffi::uniffi_bindgen_main()
281}
282"#;
283 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
284
285 Ok(())
286}
287
288pub fn generate_android_project(
299 output_dir: &Path,
300 project_slug: &str,
301 default_function: &str,
302) -> Result<(), BenchError> {
303 let target_dir = output_dir.join("android");
304 let library_name = project_slug.replace('-', "_");
305 let project_pascal = to_pascal_case(project_slug);
306 let package_id_component = sanitize_bundle_id_component(project_slug);
309 let package_name = format!("dev.world.{}", package_id_component);
310 let vars = vec![
311 TemplateVar {
312 name: "PROJECT_NAME",
313 value: project_slug.to_string(),
314 },
315 TemplateVar {
316 name: "PROJECT_NAME_PASCAL",
317 value: project_pascal.clone(),
318 },
319 TemplateVar {
320 name: "APP_NAME",
321 value: format!("{} Benchmark", project_pascal),
322 },
323 TemplateVar {
324 name: "PACKAGE_NAME",
325 value: package_name.clone(),
326 },
327 TemplateVar {
328 name: "UNIFFI_NAMESPACE",
329 value: library_name.clone(),
330 },
331 TemplateVar {
332 name: "LIBRARY_NAME",
333 value: library_name,
334 },
335 TemplateVar {
336 name: "DEFAULT_FUNCTION",
337 value: default_function.to_string(),
338 },
339 ];
340 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
341
342 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
345
346 Ok(())
347}
348
349fn move_kotlin_files_to_package_dir(
359 android_dir: &Path,
360 package_name: &str,
361) -> Result<(), BenchError> {
362 let package_path = package_name.replace('.', "/");
364
365 let main_java_dir = android_dir.join("app/src/main/java");
367 let main_package_dir = main_java_dir.join(&package_path);
368 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
369
370 let test_java_dir = android_dir.join("app/src/androidTest/java");
372 let test_package_dir = test_java_dir.join(&package_path);
373 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
374
375 Ok(())
376}
377
378fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
380 let src_file = src_dir.join(filename);
381 if !src_file.exists() {
382 return Ok(());
384 }
385
386 fs::create_dir_all(dest_dir).map_err(|e| {
388 BenchError::Build(format!(
389 "Failed to create package directory {:?}: {}",
390 dest_dir, e
391 ))
392 })?;
393
394 let dest_file = dest_dir.join(filename);
395
396 fs::copy(&src_file, &dest_file).map_err(|e| {
398 BenchError::Build(format!(
399 "Failed to copy {} to {:?}: {}",
400 filename, dest_file, e
401 ))
402 })?;
403
404 fs::remove_file(&src_file).map_err(|e| {
405 BenchError::Build(format!(
406 "Failed to remove original file {:?}: {}",
407 src_file, e
408 ))
409 })?;
410
411 Ok(())
412}
413
414pub fn generate_ios_project(
427 output_dir: &Path,
428 project_slug: &str,
429 project_pascal: &str,
430 bundle_prefix: &str,
431 default_function: &str,
432) -> Result<(), BenchError> {
433 let target_dir = output_dir.join("ios");
434 let sanitized_bundle_prefix = {
437 let parts: Vec<&str> = bundle_prefix.split('.').collect();
438 parts
439 .iter()
440 .map(|part| sanitize_bundle_id_component(part))
441 .collect::<Vec<_>>()
442 .join(".")
443 };
444 let vars = vec![
448 TemplateVar {
449 name: "DEFAULT_FUNCTION",
450 value: default_function.to_string(),
451 },
452 TemplateVar {
453 name: "PROJECT_NAME_PASCAL",
454 value: project_pascal.to_string(),
455 },
456 TemplateVar {
457 name: "BUNDLE_ID_PREFIX",
458 value: sanitized_bundle_prefix.clone(),
459 },
460 TemplateVar {
461 name: "BUNDLE_ID",
462 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
463 },
464 TemplateVar {
465 name: "LIBRARY_NAME",
466 value: project_slug.replace('-', "_"),
467 },
468 ];
469 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
470 Ok(())
471}
472
473fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
475 let config_target = match config.target {
476 Target::Ios => "ios",
477 Target::Android | Target::Both => "android",
478 };
479 let config_content = format!(
480 r#"# mobench configuration
481# This file controls how benchmarks are executed on devices.
482
483target = "{}"
484function = "example_fibonacci"
485iterations = 100
486warmup = 10
487device_matrix = "device-matrix.yaml"
488device_tags = ["default"]
489
490[browserstack]
491app_automate_username = "${{BROWSERSTACK_USERNAME}}"
492app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
493project = "{}-benchmarks"
494
495[ios_xcuitest]
496app = "target/ios/BenchRunner.ipa"
497test_suite = "target/ios/BenchRunnerUITests.zip"
498"#,
499 config_target, config.project_name
500 );
501
502 fs::write(output_dir.join("bench-config.toml"), config_content)?;
503
504 Ok(())
505}
506
507fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
509 let examples_dir = output_dir.join("benches");
510 fs::create_dir_all(&examples_dir)?;
511
512 let example_content = r#"//! Example benchmarks
513//!
514//! This file demonstrates how to write benchmarks with mobench-sdk.
515
516use mobench_sdk::benchmark;
517
518/// Simple benchmark example
519#[benchmark]
520fn example_fibonacci() {
521 let result = fibonacci(30);
522 std::hint::black_box(result);
523}
524
525/// Another example with a loop
526#[benchmark]
527fn example_sum() {
528 let mut sum = 0u64;
529 for i in 0..10000 {
530 sum = sum.wrapping_add(i);
531 }
532 std::hint::black_box(sum);
533}
534
535// Helper function (not benchmarked)
536fn fibonacci(n: u32) -> u64 {
537 match n {
538 0 => 0,
539 1 => 1,
540 _ => {
541 let mut a = 0u64;
542 let mut b = 1u64;
543 for _ in 2..=n {
544 let next = a.wrapping_add(b);
545 a = b;
546 b = next;
547 }
548 b
549 }
550 }
551}
552"#;
553
554 fs::write(examples_dir.join("example.rs"), example_content)?;
555
556 Ok(())
557}
558
559const TEMPLATE_EXTENSIONS: &[&str] = &[
561 "gradle",
562 "xml",
563 "kt",
564 "java",
565 "swift",
566 "yml",
567 "yaml",
568 "json",
569 "toml",
570 "md",
571 "txt",
572 "h",
573 "m",
574 "plist",
575 "pbxproj",
576 "xcscheme",
577 "xcworkspacedata",
578 "entitlements",
579 "modulemap",
580];
581
582fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
583 for entry in dir.entries() {
584 match entry {
585 DirEntry::Dir(sub) => {
586 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
588 continue;
589 }
590 render_dir(sub, out_root, vars)?;
591 }
592 DirEntry::File(file) => {
593 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
594 continue;
595 }
596 let mut relative = file.path().to_path_buf();
598 let mut contents = file.contents().to_vec();
599
600 let is_explicit_template = relative
602 .extension()
603 .map(|ext| ext == "template")
604 .unwrap_or(false);
605
606 let should_render = is_explicit_template || is_template_file(&relative);
608
609 if is_explicit_template {
610 relative.set_extension("");
612 }
613
614 if should_render {
615 if let Ok(text) = std::str::from_utf8(&contents) {
616 let rendered = render_template(text, vars);
617 validate_no_unreplaced_placeholders(&rendered, &relative)?;
619 contents = rendered.into_bytes();
620 }
621 }
622
623 let out_path = out_root.join(relative);
624 if let Some(parent) = out_path.parent() {
625 fs::create_dir_all(parent)?;
626 }
627 fs::write(&out_path, contents)?;
628 }
629 }
630 }
631 Ok(())
632}
633
634fn is_template_file(path: &Path) -> bool {
637 if let Some(ext) = path.extension() {
639 if ext == "template" {
640 return true;
641 }
642 if let Some(ext_str) = ext.to_str() {
644 return TEMPLATE_EXTENSIONS.contains(&ext_str);
645 }
646 }
647 if let Some(stem) = path.file_stem() {
649 let stem_path = Path::new(stem);
650 if let Some(ext) = stem_path.extension() {
651 if let Some(ext_str) = ext.to_str() {
652 return TEMPLATE_EXTENSIONS.contains(&ext_str);
653 }
654 }
655 }
656 false
657}
658
659fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
661 let mut pos = 0;
663 let mut unreplaced = Vec::new();
664
665 while let Some(start) = content[pos..].find("{{") {
666 let abs_start = pos + start;
667 if let Some(end) = content[abs_start..].find("}}") {
668 let placeholder = &content[abs_start..abs_start + end + 2];
669 let var_name = &content[abs_start + 2..abs_start + end];
671 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
674 unreplaced.push(placeholder.to_string());
675 }
676 pos = abs_start + end + 2;
677 } else {
678 break;
679 }
680 }
681
682 if !unreplaced.is_empty() {
683 return Err(BenchError::Build(format!(
684 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
685 This is a bug in mobench-sdk. Please report it at:\n\
686 https://github.com/worldcoin/mobile-bench-rs/issues",
687 file_path, unreplaced
688 )));
689 }
690
691 Ok(())
692}
693
694fn render_template(input: &str, vars: &[TemplateVar]) -> String {
695 let mut output = input.to_string();
696 for var in vars {
697 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
698 }
699 output
700}
701
702pub fn sanitize_bundle_id_component(name: &str) -> String {
713 name.chars()
714 .filter(|c| c.is_ascii_alphanumeric())
715 .collect::<String>()
716 .to_lowercase()
717}
718
719fn sanitize_package_name(name: &str) -> String {
720 name.chars()
721 .map(|c| {
722 if c.is_ascii_alphanumeric() {
723 c.to_ascii_lowercase()
724 } else {
725 '-'
726 }
727 })
728 .collect::<String>()
729 .trim_matches('-')
730 .replace("--", "-")
731}
732
733pub fn to_pascal_case(input: &str) -> String {
735 input
736 .split(|c: char| !c.is_ascii_alphanumeric())
737 .filter(|s| !s.is_empty())
738 .map(|s| {
739 let mut chars = s.chars();
740 let first = chars.next().unwrap().to_ascii_uppercase();
741 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
742 format!("{}{}", first, rest)
743 })
744 .collect::<String>()
745}
746
747pub fn android_project_exists(output_dir: &Path) -> bool {
751 let android_dir = output_dir.join("android");
752 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
753}
754
755pub fn ios_project_exists(output_dir: &Path) -> bool {
759 output_dir.join("ios/BenchRunner/project.yml").exists()
760}
761
762fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
767 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
768 let Ok(content) = std::fs::read_to_string(&project_yml) else {
769 return false;
770 };
771 let expected = format!("../{}.xcframework", library_name);
772 content.contains(&expected)
773}
774
775fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
780 let build_gradle = output_dir.join("android/app/build.gradle");
781 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
782 return false;
783 };
784 let expected = format!("lib{}.so", library_name);
785 content.contains(&expected)
786}
787
788pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
803 let lib_rs = crate_dir.join("src/lib.rs");
804 if !lib_rs.exists() {
805 return None;
806 }
807
808 let file = fs::File::open(&lib_rs).ok()?;
809 let reader = BufReader::new(file);
810
811 let mut found_benchmark_attr = false;
812 let crate_name_normalized = crate_name.replace('-', "_");
813
814 for line in reader.lines().map_while(Result::ok) {
815 let trimmed = line.trim();
816
817 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
819 found_benchmark_attr = true;
820 continue;
821 }
822
823 if found_benchmark_attr {
825 if let Some(fn_pos) = trimmed.find("fn ") {
827 let after_fn = &trimmed[fn_pos + 3..];
828 let fn_name: String = after_fn
830 .chars()
831 .take_while(|c| c.is_alphanumeric() || *c == '_')
832 .collect();
833
834 if !fn_name.is_empty() {
835 return Some(format!("{}::{}", crate_name_normalized, fn_name));
836 }
837 }
838 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
841 found_benchmark_attr = false;
842 }
843 }
844 }
845
846 None
847}
848
849pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
863 let lib_rs = crate_dir.join("src/lib.rs");
864 if !lib_rs.exists() {
865 return Vec::new();
866 }
867
868 let Ok(file) = fs::File::open(&lib_rs) else {
869 return Vec::new();
870 };
871 let reader = BufReader::new(file);
872
873 let mut benchmarks = Vec::new();
874 let mut found_benchmark_attr = false;
875 let crate_name_normalized = crate_name.replace('-', "_");
876
877 for line in reader.lines().map_while(Result::ok) {
878 let trimmed = line.trim();
879
880 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
882 found_benchmark_attr = true;
883 continue;
884 }
885
886 if found_benchmark_attr {
888 if let Some(fn_pos) = trimmed.find("fn ") {
890 let after_fn = &trimmed[fn_pos + 3..];
891 let fn_name: String = after_fn
893 .chars()
894 .take_while(|c| c.is_alphanumeric() || *c == '_')
895 .collect();
896
897 if !fn_name.is_empty() {
898 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
899 }
900 found_benchmark_attr = false;
901 }
902 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
905 found_benchmark_attr = false;
906 }
907 }
908 }
909
910 benchmarks
911}
912
913pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
925 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
926 let crate_name_normalized = crate_name.replace('-', "_");
927
928 let normalized_name = if function_name.contains("::") {
930 function_name.to_string()
931 } else {
932 format!("{}::{}", crate_name_normalized, function_name)
933 };
934
935 benchmarks.iter().any(|b| b == &normalized_name)
936}
937
938pub fn resolve_default_function(
953 project_root: &Path,
954 crate_name: &str,
955 crate_dir: Option<&Path>,
956) -> String {
957 let crate_name_normalized = crate_name.replace('-', "_");
958
959 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
961 vec![dir.to_path_buf()]
962 } else {
963 vec![
964 project_root.join("bench-mobile"),
965 project_root.join("crates").join(crate_name),
966 project_root.to_path_buf(),
967 ]
968 };
969
970 for dir in &search_dirs {
972 if dir.join("Cargo.toml").exists() {
973 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
974 return detected;
975 }
976 }
977 }
978
979 format!("{}::example_benchmark", crate_name_normalized)
981}
982
983pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
994 ensure_android_project_with_options(output_dir, crate_name, None, None)
995}
996
997pub fn ensure_android_project_with_options(
1009 output_dir: &Path,
1010 crate_name: &str,
1011 project_root: Option<&Path>,
1012 crate_dir: Option<&Path>,
1013) -> Result<(), BenchError> {
1014 let library_name = crate_name.replace('-', "_");
1015 if android_project_exists(output_dir)
1016 && android_project_matches_library(output_dir, &library_name)
1017 {
1018 return Ok(());
1019 }
1020
1021 println!("Android project not found, generating scaffolding...");
1022 let project_slug = crate_name.replace('-', "_");
1023
1024 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1026 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1027
1028 generate_android_project(output_dir, &project_slug, &default_function)?;
1029 println!(
1030 " Generated Android project at {:?}",
1031 output_dir.join("android")
1032 );
1033 println!(" Default benchmark function: {}", default_function);
1034 Ok(())
1035}
1036
1037pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1048 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1049}
1050
1051pub fn ensure_ios_project_with_options(
1063 output_dir: &Path,
1064 crate_name: &str,
1065 project_root: Option<&Path>,
1066 crate_dir: Option<&Path>,
1067) -> Result<(), BenchError> {
1068 let library_name = crate_name.replace('-', "_");
1069 if ios_project_exists(output_dir) && ios_project_matches_library(output_dir, &library_name) {
1070 return Ok(());
1071 }
1072
1073 println!("iOS project not found, generating scaffolding...");
1074 let project_pascal = "BenchRunner";
1076 let library_name = crate_name.replace('-', "_");
1078 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1081 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1082
1083 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1085 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1086
1087 generate_ios_project(
1088 output_dir,
1089 &library_name,
1090 project_pascal,
1091 &bundle_prefix,
1092 &default_function,
1093 )?;
1094 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1095 println!(" Default benchmark function: {}", default_function);
1096 Ok(())
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102 use std::env;
1103
1104 #[test]
1105 fn test_generate_bench_mobile_crate() {
1106 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1107 fs::create_dir_all(&temp_dir).unwrap();
1108
1109 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1110 assert!(result.is_ok());
1111
1112 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1114 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1115 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1116
1117 fs::remove_dir_all(&temp_dir).ok();
1119 }
1120
1121 #[test]
1122 fn test_generate_android_project_no_unreplaced_placeholders() {
1123 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1124 let _ = fs::remove_dir_all(&temp_dir);
1126 fs::create_dir_all(&temp_dir).unwrap();
1127
1128 let result =
1129 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1130 assert!(
1131 result.is_ok(),
1132 "generate_android_project failed: {:?}",
1133 result.err()
1134 );
1135
1136 let android_dir = temp_dir.join("android");
1138 assert!(android_dir.join("settings.gradle").exists());
1139 assert!(android_dir.join("app/build.gradle").exists());
1140 assert!(
1141 android_dir
1142 .join("app/src/main/AndroidManifest.xml")
1143 .exists()
1144 );
1145 assert!(
1146 android_dir
1147 .join("app/src/main/res/values/strings.xml")
1148 .exists()
1149 );
1150 assert!(
1151 android_dir
1152 .join("app/src/main/res/values/themes.xml")
1153 .exists()
1154 );
1155
1156 let files_to_check = [
1158 "settings.gradle",
1159 "app/build.gradle",
1160 "app/src/main/AndroidManifest.xml",
1161 "app/src/main/res/values/strings.xml",
1162 "app/src/main/res/values/themes.xml",
1163 ];
1164
1165 for file in files_to_check {
1166 let path = android_dir.join(file);
1167 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1168
1169 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1171 assert!(
1172 !has_placeholder,
1173 "File {} contains unreplaced template placeholders: {}",
1174 file, contents
1175 );
1176 }
1177
1178 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1180 assert!(
1181 settings.contains("my-bench-project-android")
1182 || settings.contains("my_bench_project-android"),
1183 "settings.gradle should contain project name"
1184 );
1185
1186 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1187 assert!(
1189 build_gradle.contains("dev.world.mybenchproject"),
1190 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1191 );
1192
1193 let manifest =
1194 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1195 assert!(
1196 manifest.contains("Theme.MyBenchProject"),
1197 "AndroidManifest.xml should contain PascalCase theme name"
1198 );
1199
1200 let strings =
1201 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1202 assert!(
1203 strings.contains("Benchmark"),
1204 "strings.xml should contain app name with Benchmark"
1205 );
1206
1207 let main_activity_path =
1210 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1211 assert!(
1212 main_activity_path.exists(),
1213 "MainActivity.kt should be in package directory: {:?}",
1214 main_activity_path
1215 );
1216
1217 let test_activity_path = android_dir
1218 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1219 assert!(
1220 test_activity_path.exists(),
1221 "MainActivityTest.kt should be in package directory: {:?}",
1222 test_activity_path
1223 );
1224
1225 assert!(
1227 !android_dir
1228 .join("app/src/main/java/MainActivity.kt")
1229 .exists(),
1230 "MainActivity.kt should not be in root java directory"
1231 );
1232 assert!(
1233 !android_dir
1234 .join("app/src/androidTest/java/MainActivityTest.kt")
1235 .exists(),
1236 "MainActivityTest.kt should not be in root java directory"
1237 );
1238
1239 fs::remove_dir_all(&temp_dir).ok();
1241 }
1242
1243 #[test]
1244 fn test_is_template_file() {
1245 assert!(is_template_file(Path::new("settings.gradle")));
1246 assert!(is_template_file(Path::new("app/build.gradle")));
1247 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1248 assert!(is_template_file(Path::new("strings.xml")));
1249 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1250 assert!(is_template_file(Path::new("project.yml")));
1251 assert!(is_template_file(Path::new("Info.plist")));
1252 assert!(!is_template_file(Path::new("libfoo.so")));
1253 assert!(!is_template_file(Path::new("image.png")));
1254 }
1255
1256 #[test]
1257 fn test_validate_no_unreplaced_placeholders() {
1258 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1260
1261 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1263
1264 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1266 assert!(result.is_err());
1267 let err = result.unwrap_err().to_string();
1268 assert!(err.contains("{{NAME}}"));
1269 }
1270
1271 #[test]
1272 fn test_to_pascal_case() {
1273 assert_eq!(to_pascal_case("my-project"), "MyProject");
1274 assert_eq!(to_pascal_case("my_project"), "MyProject");
1275 assert_eq!(to_pascal_case("myproject"), "Myproject");
1276 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1277 }
1278
1279 #[test]
1280 fn test_detect_default_function_finds_benchmark() {
1281 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1282 let _ = fs::remove_dir_all(&temp_dir);
1283 fs::create_dir_all(temp_dir.join("src")).unwrap();
1284
1285 let lib_content = r#"
1287use mobench_sdk::benchmark;
1288
1289/// Some docs
1290#[benchmark]
1291fn my_benchmark_func() {
1292 // benchmark code
1293}
1294
1295fn helper_func() {}
1296"#;
1297 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1298 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1299
1300 let result = detect_default_function(&temp_dir, "my_crate");
1301 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1302
1303 fs::remove_dir_all(&temp_dir).ok();
1305 }
1306
1307 #[test]
1308 fn test_detect_default_function_no_benchmark() {
1309 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1310 let _ = fs::remove_dir_all(&temp_dir);
1311 fs::create_dir_all(temp_dir.join("src")).unwrap();
1312
1313 let lib_content = r#"
1315fn regular_function() {
1316 // no benchmark here
1317}
1318"#;
1319 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1320
1321 let result = detect_default_function(&temp_dir, "my_crate");
1322 assert!(result.is_none());
1323
1324 fs::remove_dir_all(&temp_dir).ok();
1326 }
1327
1328 #[test]
1329 fn test_detect_default_function_pub_fn() {
1330 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1331 let _ = fs::remove_dir_all(&temp_dir);
1332 fs::create_dir_all(temp_dir.join("src")).unwrap();
1333
1334 let lib_content = r#"
1336#[benchmark]
1337pub fn public_bench() {
1338 // benchmark code
1339}
1340"#;
1341 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1342
1343 let result = detect_default_function(&temp_dir, "test-crate");
1344 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1345
1346 fs::remove_dir_all(&temp_dir).ok();
1348 }
1349
1350 #[test]
1351 fn test_resolve_default_function_fallback() {
1352 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1353 let _ = fs::remove_dir_all(&temp_dir);
1354 fs::create_dir_all(&temp_dir).unwrap();
1355
1356 let result = resolve_default_function(&temp_dir, "my-crate", None);
1358 assert_eq!(result, "my_crate::example_benchmark");
1359
1360 fs::remove_dir_all(&temp_dir).ok();
1362 }
1363
1364 #[test]
1365 fn test_sanitize_bundle_id_component() {
1366 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1368 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1370 assert_eq!(
1372 sanitize_bundle_id_component("my-project_name"),
1373 "myprojectname"
1374 );
1375 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1377 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1379 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1381 assert_eq!(
1383 sanitize_bundle_id_component("My-Complex_Project-123"),
1384 "mycomplexproject123"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_generate_ios_project_bundle_id_not_duplicated() {
1390 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1391 let _ = fs::remove_dir_all(&temp_dir);
1393 fs::create_dir_all(&temp_dir).unwrap();
1394
1395 let crate_name = "bench-mobile";
1397 let bundle_prefix = "dev.world.benchmobile";
1398 let project_pascal = "BenchRunner";
1399
1400 let result = generate_ios_project(
1401 &temp_dir,
1402 crate_name,
1403 project_pascal,
1404 bundle_prefix,
1405 "bench_mobile::test_func",
1406 );
1407 assert!(
1408 result.is_ok(),
1409 "generate_ios_project failed: {:?}",
1410 result.err()
1411 );
1412
1413 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1415 assert!(project_yml_path.exists(), "project.yml should exist");
1416
1417 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1419
1420 assert!(
1423 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1424 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1425 project_yml
1426 );
1427 assert!(
1428 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1429 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1430 project_yml
1431 );
1432
1433 fs::remove_dir_all(&temp_dir).ok();
1435 }
1436
1437 #[test]
1438 fn test_cross_platform_naming_consistency() {
1439 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1441 let _ = fs::remove_dir_all(&temp_dir);
1442 fs::create_dir_all(&temp_dir).unwrap();
1443
1444 let project_name = "bench-mobile";
1445
1446 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1448 assert!(
1449 result.is_ok(),
1450 "generate_android_project failed: {:?}",
1451 result.err()
1452 );
1453
1454 let bundle_id_component = sanitize_bundle_id_component(project_name);
1456 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1457 let result = generate_ios_project(
1458 &temp_dir,
1459 &project_name.replace('-', "_"),
1460 "BenchRunner",
1461 &bundle_prefix,
1462 "bench_mobile::test_func",
1463 );
1464 assert!(
1465 result.is_ok(),
1466 "generate_ios_project failed: {:?}",
1467 result.err()
1468 );
1469
1470 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1472 .expect("Failed to read Android build.gradle");
1473
1474 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1476 .expect("Failed to read iOS project.yml");
1477
1478 assert!(
1482 android_build_gradle.contains("dev.world.benchmobile"),
1483 "Android package should be 'dev.world.benchmobile', got:\n{}",
1484 android_build_gradle
1485 );
1486 assert!(
1487 ios_project_yml.contains("dev.world.benchmobile"),
1488 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1489 ios_project_yml
1490 );
1491
1492 assert!(
1494 !android_build_gradle.contains("dev.world.bench-mobile"),
1495 "Android package should NOT contain hyphens"
1496 );
1497 assert!(
1498 !android_build_gradle.contains("dev.world.bench_mobile"),
1499 "Android package should NOT contain underscores"
1500 );
1501
1502 fs::remove_dir_all(&temp_dir).ok();
1504 }
1505
1506 #[test]
1507 fn test_cross_platform_version_consistency() {
1508 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1510 let _ = fs::remove_dir_all(&temp_dir);
1511 fs::create_dir_all(&temp_dir).unwrap();
1512
1513 let project_name = "test-project";
1514
1515 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1517 assert!(
1518 result.is_ok(),
1519 "generate_android_project failed: {:?}",
1520 result.err()
1521 );
1522
1523 let bundle_id_component = sanitize_bundle_id_component(project_name);
1525 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1526 let result = generate_ios_project(
1527 &temp_dir,
1528 &project_name.replace('-', "_"),
1529 "BenchRunner",
1530 &bundle_prefix,
1531 "test_project::test_func",
1532 );
1533 assert!(
1534 result.is_ok(),
1535 "generate_ios_project failed: {:?}",
1536 result.err()
1537 );
1538
1539 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1541 .expect("Failed to read Android build.gradle");
1542
1543 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1545 .expect("Failed to read iOS project.yml");
1546
1547 assert!(
1549 android_build_gradle.contains("versionName \"1.0.0\""),
1550 "Android versionName should be '1.0.0', got:\n{}",
1551 android_build_gradle
1552 );
1553 assert!(
1554 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1555 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1556 ios_project_yml
1557 );
1558
1559 fs::remove_dir_all(&temp_dir).ok();
1561 }
1562
1563 #[test]
1564 fn test_bundle_id_prefix_consistency() {
1565 let test_cases = vec![
1567 ("my-project", "dev.world.myproject"),
1568 ("bench_mobile", "dev.world.benchmobile"),
1569 ("TestApp", "dev.world.testapp"),
1570 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1571 (
1572 "app_with_many_underscores",
1573 "dev.world.appwithmanyunderscores",
1574 ),
1575 ];
1576
1577 for (input, expected_prefix) in test_cases {
1578 let sanitized = sanitize_bundle_id_component(input);
1579 let full_prefix = format!("dev.world.{}", sanitized);
1580 assert_eq!(
1581 full_prefix, expected_prefix,
1582 "For input '{}', expected '{}' but got '{}'",
1583 input, expected_prefix, full_prefix
1584 );
1585 }
1586 }
1587}