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(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?;
64 }
65 Target::Both => {
66 generate_android_project(output_dir, &project_slug, default_function)?;
67 generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?;
68 }
69 }
70
71 generate_config_file(output_dir, config)?;
73
74 if config.generate_examples {
76 generate_example_benchmarks(output_dir)?;
77 }
78
79 Ok(output_dir.clone())
80}
81
82fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
84 let crate_dir = output_dir.join("bench-mobile");
85 fs::create_dir_all(crate_dir.join("src"))?;
86
87 let crate_name = format!("{}-bench-mobile", project_name);
88
89 let cargo_toml = format!(
93 r#"[package]
94name = "{}"
95version = "0.1.0"
96edition = "2021"
97
98[lib]
99crate-type = ["cdylib", "staticlib", "rlib"]
100
101[dependencies]
102mobench-sdk = {{ path = ".." }}
103uniffi = "0.28"
104{} = {{ path = ".." }}
105
106[features]
107default = []
108
109[build-dependencies]
110uniffi = {{ version = "0.28", features = ["build"] }}
111
112# Binary for generating UniFFI bindings (used by mobench build)
113[[bin]]
114name = "uniffi-bindgen"
115path = "src/bin/uniffi-bindgen.rs"
116
117# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
118# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
119# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
120#
121# Add this to your root Cargo.toml:
122# [workspace.dependencies]
123# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
124#
125# Then in each crate that uses rustls:
126# [dependencies]
127# rustls = {{ workspace = true }}
128"#,
129 crate_name, project_name
130 );
131
132 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
133
134 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
136//!
137//! This crate provides the FFI boundary between Rust benchmarks and mobile
138//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
139
140use uniffi;
141
142// Ensure the user crate is linked so benchmark registrations are pulled in.
143extern crate {{USER_CRATE}} as _bench_user_crate;
144
145// Re-export mobench-sdk types with UniFFI annotations
146#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
147pub struct BenchSpec {
148 pub name: String,
149 pub iterations: u32,
150 pub warmup: u32,
151}
152
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
154pub struct BenchSample {
155 pub duration_ns: u64,
156}
157
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
159pub struct BenchReport {
160 pub spec: BenchSpec,
161 pub samples: Vec<BenchSample>,
162}
163
164#[derive(Debug, thiserror::Error, uniffi::Error)]
165#[uniffi(flat_error)]
166pub enum BenchError {
167 #[error("iterations must be greater than zero")]
168 InvalidIterations,
169
170 #[error("unknown benchmark function: {name}")]
171 UnknownFunction { name: String },
172
173 #[error("benchmark execution failed: {reason}")]
174 ExecutionFailed { reason: String },
175}
176
177// Convert from mobench-sdk types
178impl From<mobench_sdk::BenchSpec> for BenchSpec {
179 fn from(spec: mobench_sdk::BenchSpec) -> Self {
180 Self {
181 name: spec.name,
182 iterations: spec.iterations,
183 warmup: spec.warmup,
184 }
185 }
186}
187
188impl From<BenchSpec> for mobench_sdk::BenchSpec {
189 fn from(spec: BenchSpec) -> Self {
190 Self {
191 name: spec.name,
192 iterations: spec.iterations,
193 warmup: spec.warmup,
194 }
195 }
196}
197
198impl From<mobench_sdk::BenchSample> for BenchSample {
199 fn from(sample: mobench_sdk::BenchSample) -> Self {
200 Self {
201 duration_ns: sample.duration_ns,
202 }
203 }
204}
205
206impl From<mobench_sdk::RunnerReport> for BenchReport {
207 fn from(report: mobench_sdk::RunnerReport) -> Self {
208 Self {
209 spec: report.spec.into(),
210 samples: report.samples.into_iter().map(Into::into).collect(),
211 }
212 }
213}
214
215impl From<mobench_sdk::BenchError> for BenchError {
216 fn from(err: mobench_sdk::BenchError) -> Self {
217 match err {
218 mobench_sdk::BenchError::Runner(runner_err) => {
219 BenchError::ExecutionFailed {
220 reason: runner_err.to_string(),
221 }
222 }
223 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
224 BenchError::UnknownFunction { name }
225 }
226 _ => BenchError::ExecutionFailed {
227 reason: err.to_string(),
228 },
229 }
230 }
231}
232
233/// Runs a benchmark by name with the given specification
234///
235/// This is the main FFI entry point called from mobile platforms.
236#[uniffi::export]
237pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
238 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
239 let report = mobench_sdk::run_benchmark(sdk_spec)?;
240 Ok(report.into())
241}
242
243// Generate UniFFI scaffolding
244uniffi::setup_scaffolding!();
245"#;
246
247 let lib_rs = render_template(
248 lib_rs_template,
249 &[TemplateVar {
250 name: "USER_CRATE",
251 value: project_name.replace('-', "_"),
252 }],
253 );
254 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
255
256 let build_rs = r#"fn main() {
258 uniffi::generate_scaffolding("src/lib.rs").unwrap();
259}
260"#;
261
262 fs::write(crate_dir.join("build.rs"), build_rs)?;
263
264 let bin_dir = crate_dir.join("src/bin");
266 fs::create_dir_all(&bin_dir)?;
267 let uniffi_bindgen_rs = r#"fn main() {
268 uniffi::uniffi_bindgen_main()
269}
270"#;
271 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
272
273 Ok(())
274}
275
276pub fn generate_android_project(
287 output_dir: &Path,
288 project_slug: &str,
289 default_function: &str,
290) -> Result<(), BenchError> {
291 let target_dir = output_dir.join("android");
292 let library_name = project_slug.replace('-', "_");
293 let project_pascal = to_pascal_case(project_slug);
294 let package_id_component = sanitize_bundle_id_component(project_slug);
297 let package_name = format!("dev.world.{}", package_id_component);
298 let vars = vec![
299 TemplateVar {
300 name: "PROJECT_NAME",
301 value: project_slug.to_string(),
302 },
303 TemplateVar {
304 name: "PROJECT_NAME_PASCAL",
305 value: project_pascal.clone(),
306 },
307 TemplateVar {
308 name: "APP_NAME",
309 value: format!("{} Benchmark", project_pascal),
310 },
311 TemplateVar {
312 name: "PACKAGE_NAME",
313 value: package_name.clone(),
314 },
315 TemplateVar {
316 name: "UNIFFI_NAMESPACE",
317 value: library_name.clone(),
318 },
319 TemplateVar {
320 name: "LIBRARY_NAME",
321 value: library_name,
322 },
323 TemplateVar {
324 name: "DEFAULT_FUNCTION",
325 value: default_function.to_string(),
326 },
327 ];
328 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
329
330 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
333
334 Ok(())
335}
336
337fn move_kotlin_files_to_package_dir(android_dir: &Path, package_name: &str) -> Result<(), BenchError> {
347 let package_path = package_name.replace('.', "/");
349
350 let main_java_dir = android_dir.join("app/src/main/java");
352 let main_package_dir = main_java_dir.join(&package_path);
353 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
354
355 let test_java_dir = android_dir.join("app/src/androidTest/java");
357 let test_package_dir = test_java_dir.join(&package_path);
358 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
359
360 Ok(())
361}
362
363fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
365 let src_file = src_dir.join(filename);
366 if !src_file.exists() {
367 return Ok(());
369 }
370
371 fs::create_dir_all(dest_dir).map_err(|e| {
373 BenchError::Build(format!(
374 "Failed to create package directory {:?}: {}",
375 dest_dir, e
376 ))
377 })?;
378
379 let dest_file = dest_dir.join(filename);
380
381 fs::copy(&src_file, &dest_file).map_err(|e| {
383 BenchError::Build(format!(
384 "Failed to copy {} to {:?}: {}",
385 filename, dest_file, e
386 ))
387 })?;
388
389 fs::remove_file(&src_file).map_err(|e| {
390 BenchError::Build(format!(
391 "Failed to remove original file {:?}: {}",
392 src_file, e
393 ))
394 })?;
395
396 Ok(())
397}
398
399pub fn generate_ios_project(
412 output_dir: &Path,
413 project_slug: &str,
414 project_pascal: &str,
415 bundle_prefix: &str,
416 default_function: &str,
417) -> Result<(), BenchError> {
418 let target_dir = output_dir.join("ios");
419 let sanitized_bundle_prefix = {
422 let parts: Vec<&str> = bundle_prefix.split('.').collect();
423 parts.iter()
424 .map(|part| sanitize_bundle_id_component(part))
425 .collect::<Vec<_>>()
426 .join(".")
427 };
428 let vars = vec![
432 TemplateVar {
433 name: "DEFAULT_FUNCTION",
434 value: default_function.to_string(),
435 },
436 TemplateVar {
437 name: "PROJECT_NAME_PASCAL",
438 value: project_pascal.to_string(),
439 },
440 TemplateVar {
441 name: "BUNDLE_ID_PREFIX",
442 value: sanitized_bundle_prefix.clone(),
443 },
444 TemplateVar {
445 name: "BUNDLE_ID",
446 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
447 },
448 TemplateVar {
449 name: "LIBRARY_NAME",
450 value: project_slug.replace('-', "_"),
451 },
452 ];
453 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
454 Ok(())
455}
456
457fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
459 let config_target = match config.target {
460 Target::Ios => "ios",
461 Target::Android | Target::Both => "android",
462 };
463 let config_content = format!(
464 r#"# mobench configuration
465# This file controls how benchmarks are executed on devices.
466
467target = "{}"
468function = "example_fibonacci"
469iterations = 100
470warmup = 10
471device_matrix = "device-matrix.yaml"
472device_tags = ["default"]
473
474[browserstack]
475app_automate_username = "${{BROWSERSTACK_USERNAME}}"
476app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
477project = "{}-benchmarks"
478
479[ios_xcuitest]
480app = "target/ios/BenchRunner.ipa"
481test_suite = "target/ios/BenchRunnerUITests.zip"
482"#,
483 config_target, config.project_name
484 );
485
486 fs::write(output_dir.join("bench-config.toml"), config_content)?;
487
488 Ok(())
489}
490
491fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
493 let examples_dir = output_dir.join("benches");
494 fs::create_dir_all(&examples_dir)?;
495
496 let example_content = r#"//! Example benchmarks
497//!
498//! This file demonstrates how to write benchmarks with mobench-sdk.
499
500use mobench_sdk::benchmark;
501
502/// Simple benchmark example
503#[benchmark]
504fn example_fibonacci() {
505 let result = fibonacci(30);
506 std::hint::black_box(result);
507}
508
509/// Another example with a loop
510#[benchmark]
511fn example_sum() {
512 let mut sum = 0u64;
513 for i in 0..10000 {
514 sum = sum.wrapping_add(i);
515 }
516 std::hint::black_box(sum);
517}
518
519// Helper function (not benchmarked)
520fn fibonacci(n: u32) -> u64 {
521 match n {
522 0 => 0,
523 1 => 1,
524 _ => {
525 let mut a = 0u64;
526 let mut b = 1u64;
527 for _ in 2..=n {
528 let next = a.wrapping_add(b);
529 a = b;
530 b = next;
531 }
532 b
533 }
534 }
535}
536"#;
537
538 fs::write(examples_dir.join("example.rs"), example_content)?;
539
540 Ok(())
541}
542
543const TEMPLATE_EXTENSIONS: &[&str] = &[
545 "gradle", "xml", "kt", "java", "swift", "yml", "yaml", "json", "toml", "md", "txt", "h", "m",
546 "plist", "pbxproj", "xcscheme", "xcworkspacedata", "entitlements", "modulemap",
547];
548
549fn render_dir(
550 dir: &Dir,
551 out_root: &Path,
552 vars: &[TemplateVar],
553) -> Result<(), BenchError> {
554 for entry in dir.entries() {
555 match entry {
556 DirEntry::Dir(sub) => {
557 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
559 continue;
560 }
561 render_dir(sub, out_root, vars)?;
562 }
563 DirEntry::File(file) => {
564 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
565 continue;
566 }
567 let mut relative = file.path().to_path_buf();
569 let mut contents = file.contents().to_vec();
570
571 let is_explicit_template = relative
573 .extension()
574 .map(|ext| ext == "template")
575 .unwrap_or(false);
576
577 let should_render = is_explicit_template || is_template_file(&relative);
579
580 if is_explicit_template {
581 relative.set_extension("");
583 }
584
585 if should_render {
586 if let Ok(text) = std::str::from_utf8(&contents) {
587 let rendered = render_template(text, vars);
588 validate_no_unreplaced_placeholders(&rendered, &relative)?;
590 contents = rendered.into_bytes();
591 }
592 }
593
594 let out_path = out_root.join(relative);
595 if let Some(parent) = out_path.parent() {
596 fs::create_dir_all(parent)?;
597 }
598 fs::write(&out_path, contents)?;
599 }
600 }
601 }
602 Ok(())
603}
604
605fn is_template_file(path: &Path) -> bool {
608 if let Some(ext) = path.extension() {
610 if ext == "template" {
611 return true;
612 }
613 if let Some(ext_str) = ext.to_str() {
615 return TEMPLATE_EXTENSIONS.contains(&ext_str);
616 }
617 }
618 if let Some(stem) = path.file_stem() {
620 let stem_path = Path::new(stem);
621 if let Some(ext) = stem_path.extension() {
622 if let Some(ext_str) = ext.to_str() {
623 return TEMPLATE_EXTENSIONS.contains(&ext_str);
624 }
625 }
626 }
627 false
628}
629
630fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
632 let mut pos = 0;
634 let mut unreplaced = Vec::new();
635
636 while let Some(start) = content[pos..].find("{{") {
637 let abs_start = pos + start;
638 if let Some(end) = content[abs_start..].find("}}") {
639 let placeholder = &content[abs_start..abs_start + end + 2];
640 let var_name = &content[abs_start + 2..abs_start + end];
642 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
645 unreplaced.push(placeholder.to_string());
646 }
647 pos = abs_start + end + 2;
648 } else {
649 break;
650 }
651 }
652
653 if !unreplaced.is_empty() {
654 return Err(BenchError::Build(format!(
655 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
656 This is a bug in mobench-sdk. Please report it at:\n\
657 https://github.com/worldcoin/mobile-bench-rs/issues",
658 file_path, unreplaced
659 )));
660 }
661
662 Ok(())
663}
664
665fn render_template(input: &str, vars: &[TemplateVar]) -> String {
666 let mut output = input.to_string();
667 for var in vars {
668 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
669 }
670 output
671}
672
673pub fn sanitize_bundle_id_component(name: &str) -> String {
684 name.chars()
685 .filter(|c| c.is_ascii_alphanumeric())
686 .collect::<String>()
687 .to_lowercase()
688}
689
690fn sanitize_package_name(name: &str) -> String {
691 name.chars()
692 .map(|c| {
693 if c.is_ascii_alphanumeric() {
694 c.to_ascii_lowercase()
695 } else {
696 '-'
697 }
698 })
699 .collect::<String>()
700 .trim_matches('-')
701 .replace("--", "-")
702}
703
704pub fn to_pascal_case(input: &str) -> String {
706 input
707 .split(|c: char| !c.is_ascii_alphanumeric())
708 .filter(|s| !s.is_empty())
709 .map(|s| {
710 let mut chars = s.chars();
711 let first = chars.next().unwrap().to_ascii_uppercase();
712 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
713 format!("{}{}", first, rest)
714 })
715 .collect::<String>()
716}
717
718pub fn android_project_exists(output_dir: &Path) -> bool {
722 let android_dir = output_dir.join("android");
723 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
724}
725
726pub fn ios_project_exists(output_dir: &Path) -> bool {
730 output_dir.join("ios/BenchRunner/project.yml").exists()
731}
732
733pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
748 let lib_rs = crate_dir.join("src/lib.rs");
749 if !lib_rs.exists() {
750 return None;
751 }
752
753 let file = fs::File::open(&lib_rs).ok()?;
754 let reader = BufReader::new(file);
755
756 let mut found_benchmark_attr = false;
757 let crate_name_normalized = crate_name.replace('-', "_");
758
759 for line in reader.lines().map_while(Result::ok) {
760 let trimmed = line.trim();
761
762 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
764 found_benchmark_attr = true;
765 continue;
766 }
767
768 if found_benchmark_attr {
770 if let Some(fn_pos) = trimmed.find("fn ") {
772 let after_fn = &trimmed[fn_pos + 3..];
773 let fn_name: String = after_fn
775 .chars()
776 .take_while(|c| c.is_alphanumeric() || *c == '_')
777 .collect();
778
779 if !fn_name.is_empty() {
780 return Some(format!("{}::{}", crate_name_normalized, fn_name));
781 }
782 }
783 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
786 found_benchmark_attr = false;
787 }
788 }
789 }
790
791 None
792}
793
794pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
808 let lib_rs = crate_dir.join("src/lib.rs");
809 if !lib_rs.exists() {
810 return Vec::new();
811 }
812
813 let Ok(file) = fs::File::open(&lib_rs) else {
814 return Vec::new();
815 };
816 let reader = BufReader::new(file);
817
818 let mut benchmarks = Vec::new();
819 let mut found_benchmark_attr = false;
820 let crate_name_normalized = crate_name.replace('-', "_");
821
822 for line in reader.lines().map_while(Result::ok) {
823 let trimmed = line.trim();
824
825 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
827 found_benchmark_attr = true;
828 continue;
829 }
830
831 if found_benchmark_attr {
833 if let Some(fn_pos) = trimmed.find("fn ") {
835 let after_fn = &trimmed[fn_pos + 3..];
836 let fn_name: String = after_fn
838 .chars()
839 .take_while(|c| c.is_alphanumeric() || *c == '_')
840 .collect();
841
842 if !fn_name.is_empty() {
843 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
844 }
845 found_benchmark_attr = false;
846 }
847 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
850 found_benchmark_attr = false;
851 }
852 }
853 }
854
855 benchmarks
856}
857
858pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
870 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
871 let crate_name_normalized = crate_name.replace('-', "_");
872
873 let normalized_name = if function_name.contains("::") {
875 function_name.to_string()
876 } else {
877 format!("{}::{}", crate_name_normalized, function_name)
878 };
879
880 benchmarks.iter().any(|b| b == &normalized_name)
881}
882
883pub fn resolve_default_function(
898 project_root: &Path,
899 crate_name: &str,
900 crate_dir: Option<&Path>,
901) -> String {
902 let crate_name_normalized = crate_name.replace('-', "_");
903
904 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
906 vec![dir.to_path_buf()]
907 } else {
908 vec![
909 project_root.join("bench-mobile"),
910 project_root.join("crates").join(crate_name),
911 project_root.to_path_buf(),
912 ]
913 };
914
915 for dir in &search_dirs {
917 if dir.join("Cargo.toml").exists() {
918 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
919 return detected;
920 }
921 }
922 }
923
924 format!("{}::example_benchmark", crate_name_normalized)
926}
927
928pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
939 ensure_android_project_with_options(output_dir, crate_name, None, None)
940}
941
942pub fn ensure_android_project_with_options(
954 output_dir: &Path,
955 crate_name: &str,
956 project_root: Option<&Path>,
957 crate_dir: Option<&Path>,
958) -> Result<(), BenchError> {
959 if android_project_exists(output_dir) {
960 return Ok(());
961 }
962
963 println!("Android project not found, generating scaffolding...");
964 let project_slug = crate_name.replace('-', "_");
965
966 let effective_root = project_root.unwrap_or_else(|| {
968 output_dir.parent().unwrap_or(output_dir)
969 });
970 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
971
972 generate_android_project(output_dir, &project_slug, &default_function)?;
973 println!(" Generated Android project at {:?}", output_dir.join("android"));
974 println!(" Default benchmark function: {}", default_function);
975 Ok(())
976}
977
978pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
989 ensure_ios_project_with_options(output_dir, crate_name, None, None)
990}
991
992pub fn ensure_ios_project_with_options(
1004 output_dir: &Path,
1005 crate_name: &str,
1006 project_root: Option<&Path>,
1007 crate_dir: Option<&Path>,
1008) -> Result<(), BenchError> {
1009 if ios_project_exists(output_dir) {
1010 return Ok(());
1011 }
1012
1013 println!("iOS project not found, generating scaffolding...");
1014 let project_pascal = "BenchRunner";
1016 let library_name = crate_name.replace('-', "_");
1018 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1021 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1022
1023 let effective_root = project_root.unwrap_or_else(|| {
1025 output_dir.parent().unwrap_or(output_dir)
1026 });
1027 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1028
1029 generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix, &default_function)?;
1030 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1031 println!(" Default benchmark function: {}", default_function);
1032 Ok(())
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038 use std::env;
1039
1040 #[test]
1041 fn test_generate_bench_mobile_crate() {
1042 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1043 fs::create_dir_all(&temp_dir).unwrap();
1044
1045 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1046 assert!(result.is_ok());
1047
1048 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1050 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1051 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1052
1053 fs::remove_dir_all(&temp_dir).ok();
1055 }
1056
1057 #[test]
1058 fn test_generate_android_project_no_unreplaced_placeholders() {
1059 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1060 let _ = fs::remove_dir_all(&temp_dir);
1062 fs::create_dir_all(&temp_dir).unwrap();
1063
1064 let result = generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1065 assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
1066
1067 let android_dir = temp_dir.join("android");
1069 assert!(android_dir.join("settings.gradle").exists());
1070 assert!(android_dir.join("app/build.gradle").exists());
1071 assert!(android_dir.join("app/src/main/AndroidManifest.xml").exists());
1072 assert!(android_dir.join("app/src/main/res/values/strings.xml").exists());
1073 assert!(android_dir.join("app/src/main/res/values/themes.xml").exists());
1074
1075 let files_to_check = [
1077 "settings.gradle",
1078 "app/build.gradle",
1079 "app/src/main/AndroidManifest.xml",
1080 "app/src/main/res/values/strings.xml",
1081 "app/src/main/res/values/themes.xml",
1082 ];
1083
1084 for file in files_to_check {
1085 let path = android_dir.join(file);
1086 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1087
1088 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1090 assert!(
1091 !has_placeholder,
1092 "File {} contains unreplaced template placeholders: {}",
1093 file,
1094 contents
1095 );
1096 }
1097
1098 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1100 assert!(
1101 settings.contains("my-bench-project-android") || settings.contains("my_bench_project-android"),
1102 "settings.gradle should contain project name"
1103 );
1104
1105 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1106 assert!(
1108 build_gradle.contains("dev.world.mybenchproject"),
1109 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1110 );
1111
1112 let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1113 assert!(
1114 manifest.contains("Theme.MyBenchProject"),
1115 "AndroidManifest.xml should contain PascalCase theme name"
1116 );
1117
1118 let strings = fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1119 assert!(
1120 strings.contains("Benchmark"),
1121 "strings.xml should contain app name with Benchmark"
1122 );
1123
1124 let main_activity_path = android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1127 assert!(
1128 main_activity_path.exists(),
1129 "MainActivity.kt should be in package directory: {:?}",
1130 main_activity_path
1131 );
1132
1133 let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1134 assert!(
1135 test_activity_path.exists(),
1136 "MainActivityTest.kt should be in package directory: {:?}",
1137 test_activity_path
1138 );
1139
1140 assert!(
1142 !android_dir.join("app/src/main/java/MainActivity.kt").exists(),
1143 "MainActivity.kt should not be in root java directory"
1144 );
1145 assert!(
1146 !android_dir.join("app/src/androidTest/java/MainActivityTest.kt").exists(),
1147 "MainActivityTest.kt should not be in root java directory"
1148 );
1149
1150 fs::remove_dir_all(&temp_dir).ok();
1152 }
1153
1154 #[test]
1155 fn test_is_template_file() {
1156 assert!(is_template_file(Path::new("settings.gradle")));
1157 assert!(is_template_file(Path::new("app/build.gradle")));
1158 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1159 assert!(is_template_file(Path::new("strings.xml")));
1160 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1161 assert!(is_template_file(Path::new("project.yml")));
1162 assert!(is_template_file(Path::new("Info.plist")));
1163 assert!(!is_template_file(Path::new("libfoo.so")));
1164 assert!(!is_template_file(Path::new("image.png")));
1165 }
1166
1167 #[test]
1168 fn test_validate_no_unreplaced_placeholders() {
1169 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1171
1172 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1174
1175 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1177 assert!(result.is_err());
1178 let err = result.unwrap_err().to_string();
1179 assert!(err.contains("{{NAME}}"));
1180 }
1181
1182 #[test]
1183 fn test_to_pascal_case() {
1184 assert_eq!(to_pascal_case("my-project"), "MyProject");
1185 assert_eq!(to_pascal_case("my_project"), "MyProject");
1186 assert_eq!(to_pascal_case("myproject"), "Myproject");
1187 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1188 }
1189
1190 #[test]
1191 fn test_detect_default_function_finds_benchmark() {
1192 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1193 let _ = fs::remove_dir_all(&temp_dir);
1194 fs::create_dir_all(temp_dir.join("src")).unwrap();
1195
1196 let lib_content = r#"
1198use mobench_sdk::benchmark;
1199
1200/// Some docs
1201#[benchmark]
1202fn my_benchmark_func() {
1203 // benchmark code
1204}
1205
1206fn helper_func() {}
1207"#;
1208 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1209 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1210
1211 let result = detect_default_function(&temp_dir, "my_crate");
1212 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1213
1214 fs::remove_dir_all(&temp_dir).ok();
1216 }
1217
1218 #[test]
1219 fn test_detect_default_function_no_benchmark() {
1220 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1221 let _ = fs::remove_dir_all(&temp_dir);
1222 fs::create_dir_all(temp_dir.join("src")).unwrap();
1223
1224 let lib_content = r#"
1226fn regular_function() {
1227 // no benchmark here
1228}
1229"#;
1230 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1231
1232 let result = detect_default_function(&temp_dir, "my_crate");
1233 assert!(result.is_none());
1234
1235 fs::remove_dir_all(&temp_dir).ok();
1237 }
1238
1239 #[test]
1240 fn test_detect_default_function_pub_fn() {
1241 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1242 let _ = fs::remove_dir_all(&temp_dir);
1243 fs::create_dir_all(temp_dir.join("src")).unwrap();
1244
1245 let lib_content = r#"
1247#[benchmark]
1248pub fn public_bench() {
1249 // benchmark code
1250}
1251"#;
1252 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1253
1254 let result = detect_default_function(&temp_dir, "test-crate");
1255 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1256
1257 fs::remove_dir_all(&temp_dir).ok();
1259 }
1260
1261 #[test]
1262 fn test_resolve_default_function_fallback() {
1263 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1264 let _ = fs::remove_dir_all(&temp_dir);
1265 fs::create_dir_all(&temp_dir).unwrap();
1266
1267 let result = resolve_default_function(&temp_dir, "my-crate", None);
1269 assert_eq!(result, "my_crate::example_benchmark");
1270
1271 fs::remove_dir_all(&temp_dir).ok();
1273 }
1274
1275 #[test]
1276 fn test_sanitize_bundle_id_component() {
1277 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1279 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1281 assert_eq!(sanitize_bundle_id_component("my-project_name"), "myprojectname");
1283 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1285 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1287 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1289 assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123");
1291 }
1292
1293 #[test]
1294 fn test_generate_ios_project_bundle_id_not_duplicated() {
1295 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1296 let _ = fs::remove_dir_all(&temp_dir);
1298 fs::create_dir_all(&temp_dir).unwrap();
1299
1300 let crate_name = "bench-mobile";
1302 let bundle_prefix = "dev.world.benchmobile";
1303 let project_pascal = "BenchRunner";
1304
1305 let result = generate_ios_project(
1306 &temp_dir,
1307 crate_name,
1308 project_pascal,
1309 bundle_prefix,
1310 "bench_mobile::test_func",
1311 );
1312 assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err());
1313
1314 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1316 assert!(project_yml_path.exists(), "project.yml should exist");
1317
1318 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1320
1321 assert!(
1324 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1325 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1326 project_yml
1327 );
1328 assert!(
1329 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1330 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1331 project_yml
1332 );
1333
1334 fs::remove_dir_all(&temp_dir).ok();
1336 }
1337
1338 #[test]
1339 fn test_cross_platform_naming_consistency() {
1340 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1342 let _ = fs::remove_dir_all(&temp_dir);
1343 fs::create_dir_all(&temp_dir).unwrap();
1344
1345 let project_name = "bench-mobile";
1346
1347 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1349 assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
1350
1351 let bundle_id_component = sanitize_bundle_id_component(project_name);
1353 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1354 let result = generate_ios_project(
1355 &temp_dir,
1356 &project_name.replace('-', "_"),
1357 "BenchRunner",
1358 &bundle_prefix,
1359 "bench_mobile::test_func",
1360 );
1361 assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err());
1362
1363 let android_build_gradle = fs::read_to_string(
1365 temp_dir.join("android/app/build.gradle")
1366 ).expect("Failed to read Android build.gradle");
1367
1368 let ios_project_yml = fs::read_to_string(
1370 temp_dir.join("ios/BenchRunner/project.yml")
1371 ).expect("Failed to read iOS project.yml");
1372
1373 assert!(
1377 android_build_gradle.contains("dev.world.benchmobile"),
1378 "Android package should be 'dev.world.benchmobile', got:\n{}",
1379 android_build_gradle
1380 );
1381 assert!(
1382 ios_project_yml.contains("dev.world.benchmobile"),
1383 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1384 ios_project_yml
1385 );
1386
1387 assert!(
1389 !android_build_gradle.contains("dev.world.bench-mobile"),
1390 "Android package should NOT contain hyphens"
1391 );
1392 assert!(
1393 !android_build_gradle.contains("dev.world.bench_mobile"),
1394 "Android package should NOT contain underscores"
1395 );
1396
1397 fs::remove_dir_all(&temp_dir).ok();
1399 }
1400
1401 #[test]
1402 fn test_cross_platform_version_consistency() {
1403 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1405 let _ = fs::remove_dir_all(&temp_dir);
1406 fs::create_dir_all(&temp_dir).unwrap();
1407
1408 let project_name = "test-project";
1409
1410 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1412 assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
1413
1414 let bundle_id_component = sanitize_bundle_id_component(project_name);
1416 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1417 let result = generate_ios_project(
1418 &temp_dir,
1419 &project_name.replace('-', "_"),
1420 "BenchRunner",
1421 &bundle_prefix,
1422 "test_project::test_func",
1423 );
1424 assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err());
1425
1426 let android_build_gradle = fs::read_to_string(
1428 temp_dir.join("android/app/build.gradle")
1429 ).expect("Failed to read Android build.gradle");
1430
1431 let ios_project_yml = fs::read_to_string(
1433 temp_dir.join("ios/BenchRunner/project.yml")
1434 ).expect("Failed to read iOS project.yml");
1435
1436 assert!(
1438 android_build_gradle.contains("versionName \"1.0.0\""),
1439 "Android versionName should be '1.0.0', got:\n{}",
1440 android_build_gradle
1441 );
1442 assert!(
1443 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1444 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1445 ios_project_yml
1446 );
1447
1448 fs::remove_dir_all(&temp_dir).ok();
1450 }
1451
1452 #[test]
1453 fn test_bundle_id_prefix_consistency() {
1454 let test_cases = vec![
1456 ("my-project", "dev.world.myproject"),
1457 ("bench_mobile", "dev.world.benchmobile"),
1458 ("TestApp", "dev.world.testapp"),
1459 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1460 ("app_with_many_underscores", "dev.world.appwithmanyunderscores"),
1461 ];
1462
1463 for (input, expected_prefix) in test_cases {
1464 let sanitized = sanitize_bundle_id_component(input);
1465 let full_prefix = format!("dev.world.{}", sanitized);
1466 assert_eq!(
1467 full_prefix, expected_prefix,
1468 "For input '{}', expected '{}' but got '{}'",
1469 input, expected_prefix, full_prefix
1470 );
1471 }
1472 }
1473}