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
762pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
777 let lib_rs = crate_dir.join("src/lib.rs");
778 if !lib_rs.exists() {
779 return None;
780 }
781
782 let file = fs::File::open(&lib_rs).ok()?;
783 let reader = BufReader::new(file);
784
785 let mut found_benchmark_attr = false;
786 let crate_name_normalized = crate_name.replace('-', "_");
787
788 for line in reader.lines().map_while(Result::ok) {
789 let trimmed = line.trim();
790
791 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
793 found_benchmark_attr = true;
794 continue;
795 }
796
797 if found_benchmark_attr {
799 if let Some(fn_pos) = trimmed.find("fn ") {
801 let after_fn = &trimmed[fn_pos + 3..];
802 let fn_name: String = after_fn
804 .chars()
805 .take_while(|c| c.is_alphanumeric() || *c == '_')
806 .collect();
807
808 if !fn_name.is_empty() {
809 return Some(format!("{}::{}", crate_name_normalized, fn_name));
810 }
811 }
812 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
815 found_benchmark_attr = false;
816 }
817 }
818 }
819
820 None
821}
822
823pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
837 let lib_rs = crate_dir.join("src/lib.rs");
838 if !lib_rs.exists() {
839 return Vec::new();
840 }
841
842 let Ok(file) = fs::File::open(&lib_rs) else {
843 return Vec::new();
844 };
845 let reader = BufReader::new(file);
846
847 let mut benchmarks = Vec::new();
848 let mut found_benchmark_attr = false;
849 let crate_name_normalized = crate_name.replace('-', "_");
850
851 for line in reader.lines().map_while(Result::ok) {
852 let trimmed = line.trim();
853
854 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
856 found_benchmark_attr = true;
857 continue;
858 }
859
860 if found_benchmark_attr {
862 if let Some(fn_pos) = trimmed.find("fn ") {
864 let after_fn = &trimmed[fn_pos + 3..];
865 let fn_name: String = after_fn
867 .chars()
868 .take_while(|c| c.is_alphanumeric() || *c == '_')
869 .collect();
870
871 if !fn_name.is_empty() {
872 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
873 }
874 found_benchmark_attr = false;
875 }
876 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
879 found_benchmark_attr = false;
880 }
881 }
882 }
883
884 benchmarks
885}
886
887pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
899 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
900 let crate_name_normalized = crate_name.replace('-', "_");
901
902 let normalized_name = if function_name.contains("::") {
904 function_name.to_string()
905 } else {
906 format!("{}::{}", crate_name_normalized, function_name)
907 };
908
909 benchmarks.iter().any(|b| b == &normalized_name)
910}
911
912pub fn resolve_default_function(
927 project_root: &Path,
928 crate_name: &str,
929 crate_dir: Option<&Path>,
930) -> String {
931 let crate_name_normalized = crate_name.replace('-', "_");
932
933 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
935 vec![dir.to_path_buf()]
936 } else {
937 vec![
938 project_root.join("bench-mobile"),
939 project_root.join("crates").join(crate_name),
940 project_root.to_path_buf(),
941 ]
942 };
943
944 for dir in &search_dirs {
946 if dir.join("Cargo.toml").exists() {
947 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
948 return detected;
949 }
950 }
951 }
952
953 format!("{}::example_benchmark", crate_name_normalized)
955}
956
957pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
968 ensure_android_project_with_options(output_dir, crate_name, None, None)
969}
970
971pub fn ensure_android_project_with_options(
983 output_dir: &Path,
984 crate_name: &str,
985 project_root: Option<&Path>,
986 crate_dir: Option<&Path>,
987) -> Result<(), BenchError> {
988 if android_project_exists(output_dir) {
989 return Ok(());
990 }
991
992 println!("Android project not found, generating scaffolding...");
993 let project_slug = crate_name.replace('-', "_");
994
995 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
997 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
998
999 generate_android_project(output_dir, &project_slug, &default_function)?;
1000 println!(
1001 " Generated Android project at {:?}",
1002 output_dir.join("android")
1003 );
1004 println!(" Default benchmark function: {}", default_function);
1005 Ok(())
1006}
1007
1008pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1019 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1020}
1021
1022pub fn ensure_ios_project_with_options(
1034 output_dir: &Path,
1035 crate_name: &str,
1036 project_root: Option<&Path>,
1037 crate_dir: Option<&Path>,
1038) -> Result<(), BenchError> {
1039 if ios_project_exists(output_dir) {
1040 return Ok(());
1041 }
1042
1043 println!("iOS project not found, generating scaffolding...");
1044 let project_pascal = "BenchRunner";
1046 let library_name = crate_name.replace('-', "_");
1048 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1051 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1052
1053 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1055 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1056
1057 generate_ios_project(
1058 output_dir,
1059 &library_name,
1060 project_pascal,
1061 &bundle_prefix,
1062 &default_function,
1063 )?;
1064 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1065 println!(" Default benchmark function: {}", default_function);
1066 Ok(())
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use std::env;
1073
1074 #[test]
1075 fn test_generate_bench_mobile_crate() {
1076 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1077 fs::create_dir_all(&temp_dir).unwrap();
1078
1079 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1080 assert!(result.is_ok());
1081
1082 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1084 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1085 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1086
1087 fs::remove_dir_all(&temp_dir).ok();
1089 }
1090
1091 #[test]
1092 fn test_generate_android_project_no_unreplaced_placeholders() {
1093 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1094 let _ = fs::remove_dir_all(&temp_dir);
1096 fs::create_dir_all(&temp_dir).unwrap();
1097
1098 let result =
1099 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1100 assert!(
1101 result.is_ok(),
1102 "generate_android_project failed: {:?}",
1103 result.err()
1104 );
1105
1106 let android_dir = temp_dir.join("android");
1108 assert!(android_dir.join("settings.gradle").exists());
1109 assert!(android_dir.join("app/build.gradle").exists());
1110 assert!(
1111 android_dir
1112 .join("app/src/main/AndroidManifest.xml")
1113 .exists()
1114 );
1115 assert!(
1116 android_dir
1117 .join("app/src/main/res/values/strings.xml")
1118 .exists()
1119 );
1120 assert!(
1121 android_dir
1122 .join("app/src/main/res/values/themes.xml")
1123 .exists()
1124 );
1125
1126 let files_to_check = [
1128 "settings.gradle",
1129 "app/build.gradle",
1130 "app/src/main/AndroidManifest.xml",
1131 "app/src/main/res/values/strings.xml",
1132 "app/src/main/res/values/themes.xml",
1133 ];
1134
1135 for file in files_to_check {
1136 let path = android_dir.join(file);
1137 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1138
1139 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1141 assert!(
1142 !has_placeholder,
1143 "File {} contains unreplaced template placeholders: {}",
1144 file, contents
1145 );
1146 }
1147
1148 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1150 assert!(
1151 settings.contains("my-bench-project-android")
1152 || settings.contains("my_bench_project-android"),
1153 "settings.gradle should contain project name"
1154 );
1155
1156 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1157 assert!(
1159 build_gradle.contains("dev.world.mybenchproject"),
1160 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1161 );
1162
1163 let manifest =
1164 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1165 assert!(
1166 manifest.contains("Theme.MyBenchProject"),
1167 "AndroidManifest.xml should contain PascalCase theme name"
1168 );
1169
1170 let strings =
1171 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1172 assert!(
1173 strings.contains("Benchmark"),
1174 "strings.xml should contain app name with Benchmark"
1175 );
1176
1177 let main_activity_path =
1180 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1181 assert!(
1182 main_activity_path.exists(),
1183 "MainActivity.kt should be in package directory: {:?}",
1184 main_activity_path
1185 );
1186
1187 let test_activity_path = android_dir
1188 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1189 assert!(
1190 test_activity_path.exists(),
1191 "MainActivityTest.kt should be in package directory: {:?}",
1192 test_activity_path
1193 );
1194
1195 assert!(
1197 !android_dir
1198 .join("app/src/main/java/MainActivity.kt")
1199 .exists(),
1200 "MainActivity.kt should not be in root java directory"
1201 );
1202 assert!(
1203 !android_dir
1204 .join("app/src/androidTest/java/MainActivityTest.kt")
1205 .exists(),
1206 "MainActivityTest.kt should not be in root java directory"
1207 );
1208
1209 fs::remove_dir_all(&temp_dir).ok();
1211 }
1212
1213 #[test]
1214 fn test_is_template_file() {
1215 assert!(is_template_file(Path::new("settings.gradle")));
1216 assert!(is_template_file(Path::new("app/build.gradle")));
1217 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1218 assert!(is_template_file(Path::new("strings.xml")));
1219 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1220 assert!(is_template_file(Path::new("project.yml")));
1221 assert!(is_template_file(Path::new("Info.plist")));
1222 assert!(!is_template_file(Path::new("libfoo.so")));
1223 assert!(!is_template_file(Path::new("image.png")));
1224 }
1225
1226 #[test]
1227 fn test_validate_no_unreplaced_placeholders() {
1228 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1230
1231 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1233
1234 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1236 assert!(result.is_err());
1237 let err = result.unwrap_err().to_string();
1238 assert!(err.contains("{{NAME}}"));
1239 }
1240
1241 #[test]
1242 fn test_to_pascal_case() {
1243 assert_eq!(to_pascal_case("my-project"), "MyProject");
1244 assert_eq!(to_pascal_case("my_project"), "MyProject");
1245 assert_eq!(to_pascal_case("myproject"), "Myproject");
1246 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1247 }
1248
1249 #[test]
1250 fn test_detect_default_function_finds_benchmark() {
1251 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1252 let _ = fs::remove_dir_all(&temp_dir);
1253 fs::create_dir_all(temp_dir.join("src")).unwrap();
1254
1255 let lib_content = r#"
1257use mobench_sdk::benchmark;
1258
1259/// Some docs
1260#[benchmark]
1261fn my_benchmark_func() {
1262 // benchmark code
1263}
1264
1265fn helper_func() {}
1266"#;
1267 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1268 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1269
1270 let result = detect_default_function(&temp_dir, "my_crate");
1271 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1272
1273 fs::remove_dir_all(&temp_dir).ok();
1275 }
1276
1277 #[test]
1278 fn test_detect_default_function_no_benchmark() {
1279 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1280 let _ = fs::remove_dir_all(&temp_dir);
1281 fs::create_dir_all(temp_dir.join("src")).unwrap();
1282
1283 let lib_content = r#"
1285fn regular_function() {
1286 // no benchmark here
1287}
1288"#;
1289 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1290
1291 let result = detect_default_function(&temp_dir, "my_crate");
1292 assert!(result.is_none());
1293
1294 fs::remove_dir_all(&temp_dir).ok();
1296 }
1297
1298 #[test]
1299 fn test_detect_default_function_pub_fn() {
1300 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1301 let _ = fs::remove_dir_all(&temp_dir);
1302 fs::create_dir_all(temp_dir.join("src")).unwrap();
1303
1304 let lib_content = r#"
1306#[benchmark]
1307pub fn public_bench() {
1308 // benchmark code
1309}
1310"#;
1311 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1312
1313 let result = detect_default_function(&temp_dir, "test-crate");
1314 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1315
1316 fs::remove_dir_all(&temp_dir).ok();
1318 }
1319
1320 #[test]
1321 fn test_resolve_default_function_fallback() {
1322 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1323 let _ = fs::remove_dir_all(&temp_dir);
1324 fs::create_dir_all(&temp_dir).unwrap();
1325
1326 let result = resolve_default_function(&temp_dir, "my-crate", None);
1328 assert_eq!(result, "my_crate::example_benchmark");
1329
1330 fs::remove_dir_all(&temp_dir).ok();
1332 }
1333
1334 #[test]
1335 fn test_sanitize_bundle_id_component() {
1336 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1338 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1340 assert_eq!(
1342 sanitize_bundle_id_component("my-project_name"),
1343 "myprojectname"
1344 );
1345 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1347 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1349 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1351 assert_eq!(
1353 sanitize_bundle_id_component("My-Complex_Project-123"),
1354 "mycomplexproject123"
1355 );
1356 }
1357
1358 #[test]
1359 fn test_generate_ios_project_bundle_id_not_duplicated() {
1360 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1361 let _ = fs::remove_dir_all(&temp_dir);
1363 fs::create_dir_all(&temp_dir).unwrap();
1364
1365 let crate_name = "bench-mobile";
1367 let bundle_prefix = "dev.world.benchmobile";
1368 let project_pascal = "BenchRunner";
1369
1370 let result = generate_ios_project(
1371 &temp_dir,
1372 crate_name,
1373 project_pascal,
1374 bundle_prefix,
1375 "bench_mobile::test_func",
1376 );
1377 assert!(
1378 result.is_ok(),
1379 "generate_ios_project failed: {:?}",
1380 result.err()
1381 );
1382
1383 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1385 assert!(project_yml_path.exists(), "project.yml should exist");
1386
1387 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1389
1390 assert!(
1393 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1394 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1395 project_yml
1396 );
1397 assert!(
1398 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1399 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1400 project_yml
1401 );
1402
1403 fs::remove_dir_all(&temp_dir).ok();
1405 }
1406
1407 #[test]
1408 fn test_cross_platform_naming_consistency() {
1409 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1411 let _ = fs::remove_dir_all(&temp_dir);
1412 fs::create_dir_all(&temp_dir).unwrap();
1413
1414 let project_name = "bench-mobile";
1415
1416 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1418 assert!(
1419 result.is_ok(),
1420 "generate_android_project failed: {:?}",
1421 result.err()
1422 );
1423
1424 let bundle_id_component = sanitize_bundle_id_component(project_name);
1426 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1427 let result = generate_ios_project(
1428 &temp_dir,
1429 &project_name.replace('-', "_"),
1430 "BenchRunner",
1431 &bundle_prefix,
1432 "bench_mobile::test_func",
1433 );
1434 assert!(
1435 result.is_ok(),
1436 "generate_ios_project failed: {:?}",
1437 result.err()
1438 );
1439
1440 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1442 .expect("Failed to read Android build.gradle");
1443
1444 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1446 .expect("Failed to read iOS project.yml");
1447
1448 assert!(
1452 android_build_gradle.contains("dev.world.benchmobile"),
1453 "Android package should be 'dev.world.benchmobile', got:\n{}",
1454 android_build_gradle
1455 );
1456 assert!(
1457 ios_project_yml.contains("dev.world.benchmobile"),
1458 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1459 ios_project_yml
1460 );
1461
1462 assert!(
1464 !android_build_gradle.contains("dev.world.bench-mobile"),
1465 "Android package should NOT contain hyphens"
1466 );
1467 assert!(
1468 !android_build_gradle.contains("dev.world.bench_mobile"),
1469 "Android package should NOT contain underscores"
1470 );
1471
1472 fs::remove_dir_all(&temp_dir).ok();
1474 }
1475
1476 #[test]
1477 fn test_cross_platform_version_consistency() {
1478 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1480 let _ = fs::remove_dir_all(&temp_dir);
1481 fs::create_dir_all(&temp_dir).unwrap();
1482
1483 let project_name = "test-project";
1484
1485 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1487 assert!(
1488 result.is_ok(),
1489 "generate_android_project failed: {:?}",
1490 result.err()
1491 );
1492
1493 let bundle_id_component = sanitize_bundle_id_component(project_name);
1495 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1496 let result = generate_ios_project(
1497 &temp_dir,
1498 &project_name.replace('-', "_"),
1499 "BenchRunner",
1500 &bundle_prefix,
1501 "test_project::test_func",
1502 );
1503 assert!(
1504 result.is_ok(),
1505 "generate_ios_project failed: {:?}",
1506 result.err()
1507 );
1508
1509 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1511 .expect("Failed to read Android build.gradle");
1512
1513 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1515 .expect("Failed to read iOS project.yml");
1516
1517 assert!(
1519 android_build_gradle.contains("versionName \"1.0.0\""),
1520 "Android versionName should be '1.0.0', got:\n{}",
1521 android_build_gradle
1522 );
1523 assert!(
1524 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1525 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1526 ios_project_yml
1527 );
1528
1529 fs::remove_dir_all(&temp_dir).ok();
1531 }
1532
1533 #[test]
1534 fn test_bundle_id_prefix_consistency() {
1535 let test_cases = vec![
1537 ("my-project", "dev.world.myproject"),
1538 ("bench_mobile", "dev.world.benchmobile"),
1539 ("TestApp", "dev.world.testapp"),
1540 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1541 (
1542 "app_with_many_underscores",
1543 "dev.world.appwithmanyunderscores",
1544 ),
1545 ];
1546
1547 for (input, expected_prefix) in test_cases {
1548 let sanitized = sanitize_bundle_id_component(input);
1549 let full_prefix = format!("dev.world.{}", sanitized);
1550 assert_eq!(
1551 full_prefix, expected_prefix,
1552 "For input '{}', expected '{}' but got '{}'",
1553 input, expected_prefix, full_prefix
1554 );
1555 }
1556 }
1557}