1use crate::types::{BenchError, InitConfig, Target};
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10
11use include_dir::{Dir, DirEntry, include_dir};
12
13const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android");
14const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios");
15
16#[derive(Debug, Clone)]
18pub struct TemplateVar {
19 pub name: &'static str,
20 pub value: String,
21}
22
23pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
40 let output_dir = &config.output_dir;
41 let project_slug = sanitize_package_name(&config.project_name);
42 let project_pascal = to_pascal_case(&project_slug);
43 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
45 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
46
47 fs::create_dir_all(output_dir)?;
49
50 generate_bench_mobile_crate(output_dir, &project_slug)?;
52
53 let default_function = "example_fibonacci";
56
57 match config.target {
59 Target::Android => {
60 generate_android_project(output_dir, &project_slug, default_function)?;
61 }
62 Target::Ios => {
63 generate_ios_project(
64 output_dir,
65 &project_slug,
66 &project_pascal,
67 &bundle_prefix,
68 default_function,
69 )?;
70 }
71 Target::Both => {
72 generate_android_project(output_dir, &project_slug, default_function)?;
73 generate_ios_project(
74 output_dir,
75 &project_slug,
76 &project_pascal,
77 &bundle_prefix,
78 default_function,
79 )?;
80 }
81 }
82
83 generate_config_file(output_dir, config)?;
85
86 if config.generate_examples {
88 generate_example_benchmarks(output_dir)?;
89 }
90
91 Ok(output_dir.clone())
92}
93
94fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
96 let crate_dir = output_dir.join("bench-mobile");
97 fs::create_dir_all(crate_dir.join("src"))?;
98
99 let crate_name = format!("{}-bench-mobile", project_name);
100
101 let cargo_toml = format!(
105 r#"[package]
106name = "{}"
107version = "0.1.0"
108edition = "2021"
109
110[lib]
111crate-type = ["cdylib", "staticlib", "rlib"]
112
113[dependencies]
114mobench-sdk = {{ path = ".." }}
115uniffi = "0.28"
116{} = {{ path = ".." }}
117
118[features]
119default = []
120
121[build-dependencies]
122uniffi = {{ version = "0.28", features = ["build"] }}
123
124# Binary for generating UniFFI bindings (used by mobench build)
125[[bin]]
126name = "uniffi-bindgen"
127path = "src/bin/uniffi-bindgen.rs"
128
129# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
130# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
131# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
132#
133# Add this to your root Cargo.toml:
134# [workspace.dependencies]
135# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
136#
137# Then in each crate that uses rustls:
138# [dependencies]
139# rustls = {{ workspace = true }}
140"#,
141 crate_name, project_name
142 );
143
144 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
145
146 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
148//!
149//! This crate provides the FFI boundary between Rust benchmarks and mobile
150//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
151
152use uniffi;
153
154// Ensure the user crate is linked so benchmark registrations are pulled in.
155extern crate {{USER_CRATE}} as _bench_user_crate;
156
157// Re-export mobench-sdk types with UniFFI annotations
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
159pub struct BenchSpec {
160 pub name: String,
161 pub iterations: u32,
162 pub warmup: u32,
163}
164
165#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
166pub struct BenchSample {
167 pub duration_ns: u64,
168}
169
170#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
171pub struct SemanticPhase {
172 pub name: String,
173 pub duration_ns: u64,
174}
175
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
177pub struct BenchResourceUsage {
178 pub cpu_median_ms: Option<u64>,
179 pub peak_memory_kb: Option<u64>,
180}
181
182#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
183pub struct BenchReport {
184 pub spec: BenchSpec,
185 pub samples: Vec<BenchSample>,
186 pub phases: Vec<SemanticPhase>,
187 pub resource_usage: Option<BenchResourceUsage>,
188}
189
190#[derive(Debug, thiserror::Error, uniffi::Error)]
191#[uniffi(flat_error)]
192pub enum BenchError {
193 #[error("iterations must be greater than zero")]
194 InvalidIterations,
195
196 #[error("unknown benchmark function: {name}")]
197 UnknownFunction { name: String },
198
199 #[error("benchmark execution failed: {reason}")]
200 ExecutionFailed { reason: String },
201}
202
203// Convert from mobench-sdk types
204impl From<mobench_sdk::BenchSpec> for BenchSpec {
205 fn from(spec: mobench_sdk::BenchSpec) -> Self {
206 Self {
207 name: spec.name,
208 iterations: spec.iterations,
209 warmup: spec.warmup,
210 }
211 }
212}
213
214impl From<BenchSpec> for mobench_sdk::BenchSpec {
215 fn from(spec: BenchSpec) -> Self {
216 Self {
217 name: spec.name,
218 iterations: spec.iterations,
219 warmup: spec.warmup,
220 }
221 }
222}
223
224impl From<mobench_sdk::BenchSample> for BenchSample {
225 fn from(sample: mobench_sdk::BenchSample) -> Self {
226 Self {
227 duration_ns: sample.duration_ns,
228 }
229 }
230}
231
232impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
233 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
234 Self {
235 name: phase.name,
236 duration_ns: phase.duration_ns,
237 }
238 }
239}
240
241impl From<mobench_sdk::BenchResourceUsage> for BenchResourceUsage {
242 fn from(resource_usage: mobench_sdk::BenchResourceUsage) -> Self {
243 Self {
244 cpu_median_ms: resource_usage.cpu_median_ms,
245 peak_memory_kb: resource_usage.peak_memory_kb,
246 }
247 }
248}
249
250impl From<mobench_sdk::RunnerReport> for BenchReport {
251 fn from(report: mobench_sdk::RunnerReport) -> Self {
252 Self {
253 spec: report.spec.into(),
254 samples: report.samples.into_iter().map(Into::into).collect(),
255 phases: report.phases.into_iter().map(Into::into).collect(),
256 resource_usage: report.resource_usage.map(Into::into),
257 }
258 }
259}
260
261impl From<mobench_sdk::BenchError> for BenchError {
262 fn from(err: mobench_sdk::BenchError) -> Self {
263 match err {
264 mobench_sdk::BenchError::Runner(runner_err) => {
265 BenchError::ExecutionFailed {
266 reason: runner_err.to_string(),
267 }
268 }
269 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
270 BenchError::UnknownFunction { name }
271 }
272 _ => BenchError::ExecutionFailed {
273 reason: err.to_string(),
274 },
275 }
276 }
277}
278
279/// Runs a benchmark by name with the given specification
280///
281/// This is the main FFI entry point called from mobile platforms.
282#[uniffi::export]
283pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
284 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
285 let report = mobench_sdk::run_benchmark(sdk_spec)?;
286 Ok(report.into())
287}
288
289// Generate UniFFI scaffolding
290uniffi::setup_scaffolding!();
291"#;
292
293 let lib_rs = render_template(
294 lib_rs_template,
295 &[TemplateVar {
296 name: "USER_CRATE",
297 value: project_name.replace('-', "_"),
298 }],
299 );
300 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
301
302 let build_rs = r#"fn main() {
304 uniffi::generate_scaffolding("src/lib.rs").unwrap();
305}
306"#;
307
308 fs::write(crate_dir.join("build.rs"), build_rs)?;
309
310 let bin_dir = crate_dir.join("src/bin");
312 fs::create_dir_all(&bin_dir)?;
313 let uniffi_bindgen_rs = r#"fn main() {
314 uniffi::uniffi_bindgen_main()
315}
316"#;
317 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
318
319 Ok(())
320}
321
322pub fn generate_android_project(
333 output_dir: &Path,
334 project_slug: &str,
335 default_function: &str,
336) -> Result<(), BenchError> {
337 let target_dir = output_dir.join("android");
338 reset_generated_project_dir(&target_dir)?;
339 let library_name = project_slug.replace('-', "_");
340 let project_pascal = to_pascal_case(project_slug);
341 let package_id_component = sanitize_bundle_id_component(project_slug);
344 let package_name = format!("dev.world.{}", package_id_component);
345 let vars = vec![
346 TemplateVar {
347 name: "PROJECT_NAME",
348 value: project_slug.to_string(),
349 },
350 TemplateVar {
351 name: "PROJECT_NAME_PASCAL",
352 value: project_pascal.clone(),
353 },
354 TemplateVar {
355 name: "APP_NAME",
356 value: format!("{} Benchmark", project_pascal),
357 },
358 TemplateVar {
359 name: "PACKAGE_NAME",
360 value: package_name.clone(),
361 },
362 TemplateVar {
363 name: "UNIFFI_NAMESPACE",
364 value: library_name.clone(),
365 },
366 TemplateVar {
367 name: "LIBRARY_NAME",
368 value: library_name,
369 },
370 TemplateVar {
371 name: "DEFAULT_FUNCTION",
372 value: default_function.to_string(),
373 },
374 ];
375 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
376
377 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
380
381 Ok(())
382}
383
384fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
385 if target_dir.exists() {
386 fs::remove_dir_all(target_dir).map_err(|e| {
387 BenchError::Build(format!(
388 "Failed to clear existing generated project at {:?}: {}",
389 target_dir, e
390 ))
391 })?;
392 }
393 Ok(())
394}
395
396fn move_kotlin_files_to_package_dir(
406 android_dir: &Path,
407 package_name: &str,
408) -> Result<(), BenchError> {
409 let package_path = package_name.replace('.', "/");
411
412 let main_java_dir = android_dir.join("app/src/main/java");
414 let main_package_dir = main_java_dir.join(&package_path);
415 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
416
417 let test_java_dir = android_dir.join("app/src/androidTest/java");
419 let test_package_dir = test_java_dir.join(&package_path);
420 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
421
422 Ok(())
423}
424
425fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
427 let src_file = src_dir.join(filename);
428 if !src_file.exists() {
429 return Ok(());
431 }
432
433 fs::create_dir_all(dest_dir).map_err(|e| {
435 BenchError::Build(format!(
436 "Failed to create package directory {:?}: {}",
437 dest_dir, e
438 ))
439 })?;
440
441 let dest_file = dest_dir.join(filename);
442
443 fs::copy(&src_file, &dest_file).map_err(|e| {
445 BenchError::Build(format!(
446 "Failed to copy {} to {:?}: {}",
447 filename, dest_file, e
448 ))
449 })?;
450
451 fs::remove_file(&src_file).map_err(|e| {
452 BenchError::Build(format!(
453 "Failed to remove original file {:?}: {}",
454 src_file, e
455 ))
456 })?;
457
458 Ok(())
459}
460
461pub fn generate_ios_project(
474 output_dir: &Path,
475 project_slug: &str,
476 project_pascal: &str,
477 bundle_prefix: &str,
478 default_function: &str,
479) -> Result<(), BenchError> {
480 let target_dir = output_dir.join("ios");
481 reset_generated_project_dir(&target_dir)?;
482 let sanitized_bundle_prefix = {
485 let parts: Vec<&str> = bundle_prefix.split('.').collect();
486 parts
487 .iter()
488 .map(|part| sanitize_bundle_id_component(part))
489 .collect::<Vec<_>>()
490 .join(".")
491 };
492 let vars = vec![
496 TemplateVar {
497 name: "DEFAULT_FUNCTION",
498 value: default_function.to_string(),
499 },
500 TemplateVar {
501 name: "PROJECT_NAME_PASCAL",
502 value: project_pascal.to_string(),
503 },
504 TemplateVar {
505 name: "BUNDLE_ID_PREFIX",
506 value: sanitized_bundle_prefix.clone(),
507 },
508 TemplateVar {
509 name: "BUNDLE_ID",
510 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
511 },
512 TemplateVar {
513 name: "LIBRARY_NAME",
514 value: project_slug.replace('-', "_"),
515 },
516 ];
517 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
518 Ok(())
519}
520
521fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
523 let config_target = match config.target {
524 Target::Ios => "ios",
525 Target::Android | Target::Both => "android",
526 };
527 let config_content = format!(
528 r#"# mobench configuration
529# This file controls how benchmarks are executed on devices.
530
531target = "{}"
532function = "example_fibonacci"
533iterations = 100
534warmup = 10
535device_matrix = "device-matrix.yaml"
536device_tags = ["default"]
537
538[browserstack]
539app_automate_username = "${{BROWSERSTACK_USERNAME}}"
540app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
541project = "{}-benchmarks"
542
543[ios_xcuitest]
544app = "target/ios/BenchRunner.ipa"
545test_suite = "target/ios/BenchRunnerUITests.zip"
546"#,
547 config_target, config.project_name
548 );
549
550 fs::write(output_dir.join("bench-config.toml"), config_content)?;
551
552 Ok(())
553}
554
555fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
557 let examples_dir = output_dir.join("benches");
558 fs::create_dir_all(&examples_dir)?;
559
560 let example_content = r#"//! Example benchmarks
561//!
562//! This file demonstrates how to write benchmarks with mobench-sdk.
563
564use mobench_sdk::benchmark;
565
566/// Simple benchmark example
567#[benchmark]
568fn example_fibonacci() {
569 let result = fibonacci(30);
570 std::hint::black_box(result);
571}
572
573/// Another example with a loop
574#[benchmark]
575fn example_sum() {
576 let mut sum = 0u64;
577 for i in 0..10000 {
578 sum = sum.wrapping_add(i);
579 }
580 std::hint::black_box(sum);
581}
582
583// Helper function (not benchmarked)
584fn fibonacci(n: u32) -> u64 {
585 match n {
586 0 => 0,
587 1 => 1,
588 _ => {
589 let mut a = 0u64;
590 let mut b = 1u64;
591 for _ in 2..=n {
592 let next = a.wrapping_add(b);
593 a = b;
594 b = next;
595 }
596 b
597 }
598 }
599}
600"#;
601
602 fs::write(examples_dir.join("example.rs"), example_content)?;
603
604 Ok(())
605}
606
607const TEMPLATE_EXTENSIONS: &[&str] = &[
609 "gradle",
610 "xml",
611 "kt",
612 "java",
613 "swift",
614 "yml",
615 "yaml",
616 "json",
617 "toml",
618 "md",
619 "txt",
620 "h",
621 "m",
622 "plist",
623 "pbxproj",
624 "xcscheme",
625 "xcworkspacedata",
626 "entitlements",
627 "modulemap",
628];
629
630fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
631 for entry in dir.entries() {
632 match entry {
633 DirEntry::Dir(sub) => {
634 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
636 continue;
637 }
638 render_dir(sub, out_root, vars)?;
639 }
640 DirEntry::File(file) => {
641 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
642 continue;
643 }
644 let mut relative = file.path().to_path_buf();
646 let mut contents = file.contents().to_vec();
647
648 let is_explicit_template = relative
650 .extension()
651 .map(|ext| ext == "template")
652 .unwrap_or(false);
653
654 let should_render = is_explicit_template || is_template_file(&relative);
656
657 if is_explicit_template {
658 relative.set_extension("");
660 }
661
662 if should_render {
663 if let Ok(text) = std::str::from_utf8(&contents) {
664 let rendered = render_template(text, vars);
665 validate_no_unreplaced_placeholders(&rendered, &relative)?;
667 contents = rendered.into_bytes();
668 }
669 }
670
671 let out_path = out_root.join(relative);
672 if let Some(parent) = out_path.parent() {
673 fs::create_dir_all(parent)?;
674 }
675 fs::write(&out_path, contents)?;
676 }
677 }
678 }
679 Ok(())
680}
681
682fn is_template_file(path: &Path) -> bool {
685 if let Some(ext) = path.extension() {
687 if ext == "template" {
688 return true;
689 }
690 if let Some(ext_str) = ext.to_str() {
692 return TEMPLATE_EXTENSIONS.contains(&ext_str);
693 }
694 }
695 if let Some(stem) = path.file_stem() {
697 let stem_path = Path::new(stem);
698 if let Some(ext) = stem_path.extension() {
699 if let Some(ext_str) = ext.to_str() {
700 return TEMPLATE_EXTENSIONS.contains(&ext_str);
701 }
702 }
703 }
704 false
705}
706
707fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
709 let mut pos = 0;
711 let mut unreplaced = Vec::new();
712
713 while let Some(start) = content[pos..].find("{{") {
714 let abs_start = pos + start;
715 if let Some(end) = content[abs_start..].find("}}") {
716 let placeholder = &content[abs_start..abs_start + end + 2];
717 let var_name = &content[abs_start + 2..abs_start + end];
719 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
722 unreplaced.push(placeholder.to_string());
723 }
724 pos = abs_start + end + 2;
725 } else {
726 break;
727 }
728 }
729
730 if !unreplaced.is_empty() {
731 return Err(BenchError::Build(format!(
732 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
733 This is a bug in mobench-sdk. Please report it at:\n\
734 https://github.com/worldcoin/mobile-bench-rs/issues",
735 file_path, unreplaced
736 )));
737 }
738
739 Ok(())
740}
741
742fn render_template(input: &str, vars: &[TemplateVar]) -> String {
743 let mut output = input.to_string();
744 for var in vars {
745 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
746 }
747 output
748}
749
750pub fn sanitize_bundle_id_component(name: &str) -> String {
761 name.chars()
762 .filter(|c| c.is_ascii_alphanumeric())
763 .collect::<String>()
764 .to_lowercase()
765}
766
767fn sanitize_package_name(name: &str) -> String {
768 name.chars()
769 .map(|c| {
770 if c.is_ascii_alphanumeric() {
771 c.to_ascii_lowercase()
772 } else {
773 '-'
774 }
775 })
776 .collect::<String>()
777 .trim_matches('-')
778 .replace("--", "-")
779}
780
781pub fn to_pascal_case(input: &str) -> String {
783 input
784 .split(|c: char| !c.is_ascii_alphanumeric())
785 .filter(|s| !s.is_empty())
786 .map(|s| {
787 let mut chars = s.chars();
788 let first = chars.next().unwrap().to_ascii_uppercase();
789 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
790 format!("{}{}", first, rest)
791 })
792 .collect::<String>()
793}
794
795pub fn android_project_exists(output_dir: &Path) -> bool {
799 let android_dir = output_dir.join("android");
800 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
801}
802
803pub fn ios_project_exists(output_dir: &Path) -> bool {
807 output_dir.join("ios/BenchRunner/project.yml").exists()
808}
809
810fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
815 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
816 let Ok(content) = std::fs::read_to_string(&project_yml) else {
817 return false;
818 };
819 let expected = format!("../{}.xcframework", library_name);
820 content.contains(&expected)
821}
822
823fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
828 let build_gradle = output_dir.join("android/app/build.gradle");
829 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
830 return false;
831 };
832 let expected = format!("lib{}.so", library_name);
833 content.contains(&expected)
834}
835
836pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
851 let lib_rs = crate_dir.join("src/lib.rs");
852 if !lib_rs.exists() {
853 return None;
854 }
855
856 let file = fs::File::open(&lib_rs).ok()?;
857 let reader = BufReader::new(file);
858
859 let mut found_benchmark_attr = false;
860 let crate_name_normalized = crate_name.replace('-', "_");
861
862 for line in reader.lines().map_while(Result::ok) {
863 let trimmed = line.trim();
864
865 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
867 found_benchmark_attr = true;
868 continue;
869 }
870
871 if found_benchmark_attr {
873 if let Some(fn_pos) = trimmed.find("fn ") {
875 let after_fn = &trimmed[fn_pos + 3..];
876 let fn_name: String = after_fn
878 .chars()
879 .take_while(|c| c.is_alphanumeric() || *c == '_')
880 .collect();
881
882 if !fn_name.is_empty() {
883 return Some(format!("{}::{}", crate_name_normalized, fn_name));
884 }
885 }
886 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
889 found_benchmark_attr = false;
890 }
891 }
892 }
893
894 None
895}
896
897pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
911 let lib_rs = crate_dir.join("src/lib.rs");
912 if !lib_rs.exists() {
913 return Vec::new();
914 }
915
916 let Ok(file) = fs::File::open(&lib_rs) else {
917 return Vec::new();
918 };
919 let reader = BufReader::new(file);
920
921 let mut benchmarks = Vec::new();
922 let mut found_benchmark_attr = false;
923 let crate_name_normalized = crate_name.replace('-', "_");
924
925 for line in reader.lines().map_while(Result::ok) {
926 let trimmed = line.trim();
927
928 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
930 found_benchmark_attr = true;
931 continue;
932 }
933
934 if found_benchmark_attr {
936 if let Some(fn_pos) = trimmed.find("fn ") {
938 let after_fn = &trimmed[fn_pos + 3..];
939 let fn_name: String = after_fn
941 .chars()
942 .take_while(|c| c.is_alphanumeric() || *c == '_')
943 .collect();
944
945 if !fn_name.is_empty() {
946 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
947 }
948 found_benchmark_attr = false;
949 }
950 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
953 found_benchmark_attr = false;
954 }
955 }
956 }
957
958 benchmarks
959}
960
961pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
973 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
974 let crate_name_normalized = crate_name.replace('-', "_");
975
976 let normalized_name = if function_name.contains("::") {
978 function_name.to_string()
979 } else {
980 format!("{}::{}", crate_name_normalized, function_name)
981 };
982
983 benchmarks.iter().any(|b| b == &normalized_name)
984}
985
986pub fn resolve_default_function(
1001 project_root: &Path,
1002 crate_name: &str,
1003 crate_dir: Option<&Path>,
1004) -> String {
1005 let crate_name_normalized = crate_name.replace('-', "_");
1006
1007 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1009 vec![dir.to_path_buf()]
1010 } else {
1011 vec![
1012 project_root.join("bench-mobile"),
1013 project_root.join("crates").join(crate_name),
1014 project_root.to_path_buf(),
1015 ]
1016 };
1017
1018 for dir in &search_dirs {
1020 if dir.join("Cargo.toml").exists() {
1021 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
1022 return detected;
1023 }
1024 }
1025 }
1026
1027 format!("{}::example_benchmark", crate_name_normalized)
1029}
1030
1031pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1042 ensure_android_project_with_options(output_dir, crate_name, None, None)
1043}
1044
1045pub fn ensure_android_project_with_options(
1057 output_dir: &Path,
1058 crate_name: &str,
1059 project_root: Option<&Path>,
1060 crate_dir: Option<&Path>,
1061) -> Result<(), BenchError> {
1062 let library_name = crate_name.replace('-', "_");
1063 let project_exists = android_project_exists(output_dir);
1064 let project_matches = android_project_matches_library(output_dir, &library_name);
1065 if project_exists && !project_matches {
1066 println!("Existing Android scaffolding does not match library, regenerating...");
1067 } else if project_exists {
1068 println!("Refreshing generated Android scaffolding...");
1069 } else {
1070 println!("Android project not found, generating scaffolding...");
1071 }
1072 let project_slug = crate_name.replace('-', "_");
1073
1074 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1076 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1077
1078 generate_android_project(output_dir, &project_slug, &default_function)?;
1079 println!(
1080 " Generated Android project at {:?}",
1081 output_dir.join("android")
1082 );
1083 println!(" Default benchmark function: {}", default_function);
1084 Ok(())
1085}
1086
1087pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1098 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1099}
1100
1101pub fn ensure_ios_project_with_options(
1113 output_dir: &Path,
1114 crate_name: &str,
1115 project_root: Option<&Path>,
1116 crate_dir: Option<&Path>,
1117) -> Result<(), BenchError> {
1118 let library_name = crate_name.replace('-', "_");
1119 let project_exists = ios_project_exists(output_dir);
1120 let project_matches = ios_project_matches_library(output_dir, &library_name);
1121 if project_exists && !project_matches {
1122 println!("Existing iOS scaffolding does not match library, regenerating...");
1123 } else if project_exists {
1124 println!("Refreshing generated iOS scaffolding...");
1125 } else {
1126 println!("iOS project not found, generating scaffolding...");
1127 }
1128
1129 let project_pascal = "BenchRunner";
1131 let library_name = crate_name.replace('-', "_");
1133 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1136 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1137
1138 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1140 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1141
1142 generate_ios_project(
1143 output_dir,
1144 &library_name,
1145 project_pascal,
1146 &bundle_prefix,
1147 &default_function,
1148 )?;
1149 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1150 println!(" Default benchmark function: {}", default_function);
1151 Ok(())
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157 use std::env;
1158
1159 #[test]
1160 fn test_generate_bench_mobile_crate() {
1161 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1162 fs::create_dir_all(&temp_dir).unwrap();
1163
1164 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1165 assert!(result.is_ok());
1166
1167 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1169 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1170 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1171
1172 fs::remove_dir_all(&temp_dir).ok();
1174 }
1175
1176 #[test]
1177 fn test_generate_android_project_no_unreplaced_placeholders() {
1178 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1179 let _ = fs::remove_dir_all(&temp_dir);
1181 fs::create_dir_all(&temp_dir).unwrap();
1182
1183 let result =
1184 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1185 assert!(
1186 result.is_ok(),
1187 "generate_android_project failed: {:?}",
1188 result.err()
1189 );
1190
1191 let android_dir = temp_dir.join("android");
1193 assert!(android_dir.join("settings.gradle").exists());
1194 assert!(android_dir.join("app/build.gradle").exists());
1195 assert!(
1196 android_dir
1197 .join("app/src/main/AndroidManifest.xml")
1198 .exists()
1199 );
1200 assert!(
1201 android_dir
1202 .join("app/src/main/res/values/strings.xml")
1203 .exists()
1204 );
1205 assert!(
1206 android_dir
1207 .join("app/src/main/res/values/themes.xml")
1208 .exists()
1209 );
1210
1211 let files_to_check = [
1213 "settings.gradle",
1214 "app/build.gradle",
1215 "app/src/main/AndroidManifest.xml",
1216 "app/src/main/res/values/strings.xml",
1217 "app/src/main/res/values/themes.xml",
1218 ];
1219
1220 for file in files_to_check {
1221 let path = android_dir.join(file);
1222 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1223
1224 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1226 assert!(
1227 !has_placeholder,
1228 "File {} contains unreplaced template placeholders: {}",
1229 file, contents
1230 );
1231 }
1232
1233 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1235 assert!(
1236 settings.contains("my-bench-project-android")
1237 || settings.contains("my_bench_project-android"),
1238 "settings.gradle should contain project name"
1239 );
1240
1241 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1242 assert!(
1244 build_gradle.contains("dev.world.mybenchproject"),
1245 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1246 );
1247
1248 let manifest =
1249 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1250 assert!(
1251 manifest.contains("Theme.MyBenchProject"),
1252 "AndroidManifest.xml should contain PascalCase theme name"
1253 );
1254
1255 let strings =
1256 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1257 assert!(
1258 strings.contains("Benchmark"),
1259 "strings.xml should contain app name with Benchmark"
1260 );
1261
1262 let main_activity_path =
1265 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1266 assert!(
1267 main_activity_path.exists(),
1268 "MainActivity.kt should be in package directory: {:?}",
1269 main_activity_path
1270 );
1271
1272 let test_activity_path = android_dir
1273 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1274 assert!(
1275 test_activity_path.exists(),
1276 "MainActivityTest.kt should be in package directory: {:?}",
1277 test_activity_path
1278 );
1279
1280 assert!(
1282 !android_dir
1283 .join("app/src/main/java/MainActivity.kt")
1284 .exists(),
1285 "MainActivity.kt should not be in root java directory"
1286 );
1287 assert!(
1288 !android_dir
1289 .join("app/src/androidTest/java/MainActivityTest.kt")
1290 .exists(),
1291 "MainActivityTest.kt should not be in root java directory"
1292 );
1293
1294 fs::remove_dir_all(&temp_dir).ok();
1296 }
1297
1298 #[test]
1299 fn test_generate_android_project_replaces_previous_package_tree() {
1300 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1301 let _ = fs::remove_dir_all(&temp_dir);
1302 fs::create_dir_all(&temp_dir).unwrap();
1303
1304 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1305 .unwrap();
1306 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1307 assert!(old_package_dir.exists(), "expected first package tree to exist");
1308
1309 generate_android_project(
1310 &temp_dir,
1311 "basic_benchmark",
1312 "basic_benchmark::bench_fibonacci",
1313 )
1314 .unwrap();
1315
1316 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1317 assert!(new_package_dir.exists(), "expected new package tree to exist");
1318 assert!(
1319 !old_package_dir.exists(),
1320 "old package tree should be removed when regenerating the Android scaffold"
1321 );
1322
1323 fs::remove_dir_all(&temp_dir).ok();
1324 }
1325
1326 #[test]
1327 fn test_is_template_file() {
1328 assert!(is_template_file(Path::new("settings.gradle")));
1329 assert!(is_template_file(Path::new("app/build.gradle")));
1330 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1331 assert!(is_template_file(Path::new("strings.xml")));
1332 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1333 assert!(is_template_file(Path::new("project.yml")));
1334 assert!(is_template_file(Path::new("Info.plist")));
1335 assert!(!is_template_file(Path::new("libfoo.so")));
1336 assert!(!is_template_file(Path::new("image.png")));
1337 }
1338
1339 #[test]
1340 fn test_validate_no_unreplaced_placeholders() {
1341 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1343
1344 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1346
1347 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1349 assert!(result.is_err());
1350 let err = result.unwrap_err().to_string();
1351 assert!(err.contains("{{NAME}}"));
1352 }
1353
1354 #[test]
1355 fn test_to_pascal_case() {
1356 assert_eq!(to_pascal_case("my-project"), "MyProject");
1357 assert_eq!(to_pascal_case("my_project"), "MyProject");
1358 assert_eq!(to_pascal_case("myproject"), "Myproject");
1359 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1360 }
1361
1362 #[test]
1363 fn test_detect_default_function_finds_benchmark() {
1364 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1365 let _ = fs::remove_dir_all(&temp_dir);
1366 fs::create_dir_all(temp_dir.join("src")).unwrap();
1367
1368 let lib_content = r#"
1370use mobench_sdk::benchmark;
1371
1372/// Some docs
1373#[benchmark]
1374fn my_benchmark_func() {
1375 // benchmark code
1376}
1377
1378fn helper_func() {}
1379"#;
1380 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1381 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1382
1383 let result = detect_default_function(&temp_dir, "my_crate");
1384 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1385
1386 fs::remove_dir_all(&temp_dir).ok();
1388 }
1389
1390 #[test]
1391 fn test_detect_default_function_no_benchmark() {
1392 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1393 let _ = fs::remove_dir_all(&temp_dir);
1394 fs::create_dir_all(temp_dir.join("src")).unwrap();
1395
1396 let lib_content = r#"
1398fn regular_function() {
1399 // no benchmark here
1400}
1401"#;
1402 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1403
1404 let result = detect_default_function(&temp_dir, "my_crate");
1405 assert!(result.is_none());
1406
1407 fs::remove_dir_all(&temp_dir).ok();
1409 }
1410
1411 #[test]
1412 fn test_detect_default_function_pub_fn() {
1413 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1414 let _ = fs::remove_dir_all(&temp_dir);
1415 fs::create_dir_all(temp_dir.join("src")).unwrap();
1416
1417 let lib_content = r#"
1419#[benchmark]
1420pub fn public_bench() {
1421 // benchmark code
1422}
1423"#;
1424 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1425
1426 let result = detect_default_function(&temp_dir, "test-crate");
1427 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1428
1429 fs::remove_dir_all(&temp_dir).ok();
1431 }
1432
1433 #[test]
1434 fn test_resolve_default_function_fallback() {
1435 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1436 let _ = fs::remove_dir_all(&temp_dir);
1437 fs::create_dir_all(&temp_dir).unwrap();
1438
1439 let result = resolve_default_function(&temp_dir, "my-crate", None);
1441 assert_eq!(result, "my_crate::example_benchmark");
1442
1443 fs::remove_dir_all(&temp_dir).ok();
1445 }
1446
1447 #[test]
1448 fn test_sanitize_bundle_id_component() {
1449 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1451 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1453 assert_eq!(
1455 sanitize_bundle_id_component("my-project_name"),
1456 "myprojectname"
1457 );
1458 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1460 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1462 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1464 assert_eq!(
1466 sanitize_bundle_id_component("My-Complex_Project-123"),
1467 "mycomplexproject123"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_generate_ios_project_bundle_id_not_duplicated() {
1473 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1474 let _ = fs::remove_dir_all(&temp_dir);
1476 fs::create_dir_all(&temp_dir).unwrap();
1477
1478 let crate_name = "bench-mobile";
1480 let bundle_prefix = "dev.world.benchmobile";
1481 let project_pascal = "BenchRunner";
1482
1483 let result = generate_ios_project(
1484 &temp_dir,
1485 crate_name,
1486 project_pascal,
1487 bundle_prefix,
1488 "bench_mobile::test_func",
1489 );
1490 assert!(
1491 result.is_ok(),
1492 "generate_ios_project failed: {:?}",
1493 result.err()
1494 );
1495
1496 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1498 assert!(project_yml_path.exists(), "project.yml should exist");
1499
1500 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1502
1503 assert!(
1506 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1507 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1508 project_yml
1509 );
1510 assert!(
1511 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1512 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1513 project_yml
1514 );
1515 assert!(
1516 project_yml.contains("embed: false"),
1517 "Static xcframework dependency should be link-only, got:\n{}",
1518 project_yml
1519 );
1520
1521 fs::remove_dir_all(&temp_dir).ok();
1523 }
1524
1525 #[test]
1526 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1527 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1528 let _ = fs::remove_dir_all(&temp_dir);
1529 fs::create_dir_all(&temp_dir).unwrap();
1530
1531 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1532 .expect("initial iOS project generation should succeed");
1533
1534 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1535 assert!(content_view_path.exists(), "ContentView.swift should exist");
1536
1537 fs::write(&content_view_path, "stale generated content").unwrap();
1538
1539 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1540 .expect("refreshing existing iOS project should succeed");
1541
1542 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1543 assert!(
1544 refreshed.contains("ProfileLaunchOptions"),
1545 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1546 refreshed
1547 );
1548 assert!(
1549 refreshed.contains("repeatUntilMs"),
1550 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1551 refreshed
1552 );
1553
1554 fs::remove_dir_all(&temp_dir).ok();
1555 }
1556
1557 #[test]
1558 fn test_ensure_android_project_refreshes_existing_main_activity_template() {
1559 let temp_dir = env::temp_dir().join("mobench-sdk-android-refresh-test");
1560 let _ = fs::remove_dir_all(&temp_dir);
1561 fs::create_dir_all(&temp_dir).unwrap();
1562
1563 ensure_android_project_with_options(&temp_dir, "sample-fns", None, None)
1564 .expect("initial Android project generation should succeed");
1565
1566 let main_activity_path =
1567 temp_dir.join("android/app/src/main/java/dev/world/samplefns/MainActivity.kt");
1568 assert!(main_activity_path.exists(), "MainActivity.kt should exist");
1569
1570 fs::write(&main_activity_path, "stale generated content").unwrap();
1571
1572 ensure_android_project_with_options(&temp_dir, "sample-fns", None, None)
1573 .expect("refreshing existing Android project should succeed");
1574
1575 let refreshed = fs::read_to_string(&main_activity_path).unwrap();
1576 assert!(
1577 refreshed.contains("cpu_median_ms"),
1578 "refreshed MainActivity.kt should contain the latest CPU logging template, got:\n{}",
1579 refreshed
1580 );
1581 assert!(
1582 refreshed.contains("peak_memory_kb"),
1583 "refreshed MainActivity.kt should contain measured peak memory logging, got:\n{}",
1584 refreshed
1585 );
1586
1587 fs::remove_dir_all(&temp_dir).ok();
1588 }
1589
1590 #[test]
1591 fn test_cross_platform_naming_consistency() {
1592 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1594 let _ = fs::remove_dir_all(&temp_dir);
1595 fs::create_dir_all(&temp_dir).unwrap();
1596
1597 let project_name = "bench-mobile";
1598
1599 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1601 assert!(
1602 result.is_ok(),
1603 "generate_android_project failed: {:?}",
1604 result.err()
1605 );
1606
1607 let bundle_id_component = sanitize_bundle_id_component(project_name);
1609 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1610 let result = generate_ios_project(
1611 &temp_dir,
1612 &project_name.replace('-', "_"),
1613 "BenchRunner",
1614 &bundle_prefix,
1615 "bench_mobile::test_func",
1616 );
1617 assert!(
1618 result.is_ok(),
1619 "generate_ios_project failed: {:?}",
1620 result.err()
1621 );
1622
1623 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1625 .expect("Failed to read Android build.gradle");
1626
1627 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1629 .expect("Failed to read iOS project.yml");
1630
1631 assert!(
1635 android_build_gradle.contains("dev.world.benchmobile"),
1636 "Android package should be 'dev.world.benchmobile', got:\n{}",
1637 android_build_gradle
1638 );
1639 assert!(
1640 ios_project_yml.contains("dev.world.benchmobile"),
1641 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1642 ios_project_yml
1643 );
1644
1645 assert!(
1647 !android_build_gradle.contains("dev.world.bench-mobile"),
1648 "Android package should NOT contain hyphens"
1649 );
1650 assert!(
1651 !android_build_gradle.contains("dev.world.bench_mobile"),
1652 "Android package should NOT contain underscores"
1653 );
1654
1655 fs::remove_dir_all(&temp_dir).ok();
1657 }
1658
1659 #[test]
1660 fn test_cross_platform_version_consistency() {
1661 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1663 let _ = fs::remove_dir_all(&temp_dir);
1664 fs::create_dir_all(&temp_dir).unwrap();
1665
1666 let project_name = "test-project";
1667
1668 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1670 assert!(
1671 result.is_ok(),
1672 "generate_android_project failed: {:?}",
1673 result.err()
1674 );
1675
1676 let bundle_id_component = sanitize_bundle_id_component(project_name);
1678 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1679 let result = generate_ios_project(
1680 &temp_dir,
1681 &project_name.replace('-', "_"),
1682 "BenchRunner",
1683 &bundle_prefix,
1684 "test_project::test_func",
1685 );
1686 assert!(
1687 result.is_ok(),
1688 "generate_ios_project failed: {:?}",
1689 result.err()
1690 );
1691
1692 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1694 .expect("Failed to read Android build.gradle");
1695
1696 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1698 .expect("Failed to read iOS project.yml");
1699
1700 assert!(
1702 android_build_gradle.contains("versionName \"1.0.0\""),
1703 "Android versionName should be '1.0.0', got:\n{}",
1704 android_build_gradle
1705 );
1706 assert!(
1707 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1708 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1709 ios_project_yml
1710 );
1711
1712 fs::remove_dir_all(&temp_dir).ok();
1714 }
1715
1716 #[test]
1717 fn test_bundle_id_prefix_consistency() {
1718 let test_cases = vec![
1720 ("my-project", "dev.world.myproject"),
1721 ("bench_mobile", "dev.world.benchmobile"),
1722 ("TestApp", "dev.world.testapp"),
1723 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1724 (
1725 "app_with_many_underscores",
1726 "dev.world.appwithmanyunderscores",
1727 ),
1728 ];
1729
1730 for (input, expected_prefix) in test_cases {
1731 let sanitized = sanitize_bundle_id_component(input);
1732 let full_prefix = format!("dev.world.{}", sanitized);
1733 assert_eq!(
1734 full_prefix, expected_prefix,
1735 "For input '{}', expected '{}' but got '{}'",
1736 input, expected_prefix, full_prefix
1737 );
1738 }
1739 }
1740}