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");
15pub const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
16pub const DEFAULT_IOS_DEPLOYMENT_TARGET: &str = "15.0";
17pub const SWIFTUI_RUNNER_MIN_IOS: &str = "15.0";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum IosRunner {
22 Swiftui,
24 UikitLegacy,
26}
27
28impl IosRunner {
29 pub fn parse(value: &str) -> Result<Self, BenchError> {
30 match value.trim().to_ascii_lowercase().as_str() {
31 "swiftui" => Ok(Self::Swiftui),
32 "uikit-legacy" | "uikit_legacy" => Ok(Self::UikitLegacy),
33 other => Err(BenchError::Build(format!(
34 "Unsupported iOS runner `{other}`. Supported values: swiftui, uikit-legacy"
35 ))),
36 }
37 }
38
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Swiftui => "swiftui",
42 Self::UikitLegacy => "uikit-legacy",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Eq)]
49pub struct IosDeploymentTarget {
50 major: u16,
51 minor: u16,
52 patch: u16,
53 raw: String,
54}
55
56impl PartialEq for IosDeploymentTarget {
57 fn eq(&self, other: &Self) -> bool {
58 (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
59 }
60}
61
62impl Ord for IosDeploymentTarget {
63 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
64 (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
65 }
66}
67
68impl PartialOrd for IosDeploymentTarget {
69 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
70 Some(self.cmp(other))
71 }
72}
73
74impl IosDeploymentTarget {
75 pub fn parse(value: &str) -> Result<Self, BenchError> {
76 let raw = value.trim();
77 if raw.is_empty() {
78 return Err(BenchError::Build(
79 "iOS deployment target must not be empty".to_string(),
80 ));
81 }
82
83 let parts = raw.split('.').collect::<Vec<_>>();
84 if parts.len() > 3 {
85 return Err(BenchError::Build(format!(
86 "Invalid iOS deployment target `{raw}`. Expected VERSION like 15.0"
87 )));
88 }
89
90 let major = parse_ios_version_part(raw, parts[0], "major")?;
91 let minor = parts
92 .get(1)
93 .map(|part| parse_ios_version_part(raw, part, "minor"))
94 .transpose()?
95 .unwrap_or(0);
96 let patch = parts
97 .get(2)
98 .map(|part| parse_ios_version_part(raw, part, "patch"))
99 .transpose()?
100 .unwrap_or(0);
101
102 Ok(Self {
103 major,
104 minor,
105 patch,
106 raw: raw.to_string(),
107 })
108 }
109
110 pub fn default_target() -> Self {
111 Self::parse(DEFAULT_IOS_DEPLOYMENT_TARGET)
112 .expect("default iOS deployment target should be valid")
113 }
114}
115
116fn parse_ios_version_part(raw: &str, part: &str, label: &str) -> Result<u16, BenchError> {
117 if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
118 return Err(BenchError::Build(format!(
119 "Invalid iOS deployment target `{raw}`: {label} version component must be numeric"
120 )));
121 }
122 part.parse::<u16>().map_err(|err| {
123 BenchError::Build(format!(
124 "Invalid iOS deployment target `{raw}`: failed to parse {label} component: {err}"
125 ))
126 })
127}
128
129impl std::fmt::Display for IosDeploymentTarget {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.write_str(&self.raw)
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct IosProjectOptions {
138 pub deployment_target: IosDeploymentTarget,
139 pub runner: IosRunner,
140 pub ios_benchmark_timeout_secs: u64,
141}
142
143impl Default for IosProjectOptions {
144 fn default() -> Self {
145 Self {
146 deployment_target: IosDeploymentTarget::default_target(),
147 runner: IosRunner::Swiftui,
148 ios_benchmark_timeout_secs: DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS,
149 }
150 }
151}
152
153pub fn resolve_ios_runner(
154 deployment_target: &IosDeploymentTarget,
155 requested_runner: Option<IosRunner>,
156) -> Result<IosRunner, BenchError> {
157 let swiftui_floor = IosDeploymentTarget::parse(SWIFTUI_RUNNER_MIN_IOS)?;
158 match requested_runner {
159 Some(IosRunner::Swiftui) if deployment_target < &swiftui_floor => {
160 Err(BenchError::Build(format!(
161 "iOS runner `swiftui` requires deployment target {SWIFTUI_RUNNER_MIN_IOS}+; \
162 requested deployment target is {deployment_target}. Use `uikit-legacy` or raise the deployment target."
163 )))
164 }
165 Some(runner) => Ok(runner),
166 None if deployment_target < &swiftui_floor => Ok(IosRunner::UikitLegacy),
167 None => Ok(IosRunner::Swiftui),
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct TemplateVar {
174 pub name: &'static str,
175 pub value: String,
176}
177
178pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
195 let output_dir = &config.output_dir;
196 let project_slug = sanitize_package_name(&config.project_name);
197 let project_pascal = to_pascal_case(&project_slug);
198 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
200 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
201
202 fs::create_dir_all(output_dir)?;
204
205 generate_bench_mobile_crate(output_dir, &project_slug)?;
207
208 let default_function = "example_fibonacci";
211
212 match config.target {
214 Target::Android => {
215 generate_android_project(output_dir, &project_slug, default_function)?;
216 }
217 Target::Ios => {
218 generate_ios_project(
219 output_dir,
220 &project_slug,
221 &project_pascal,
222 &bundle_prefix,
223 default_function,
224 )?;
225 }
226 Target::Both => {
227 generate_android_project(output_dir, &project_slug, default_function)?;
228 generate_ios_project(
229 output_dir,
230 &project_slug,
231 &project_pascal,
232 &bundle_prefix,
233 default_function,
234 )?;
235 }
236 }
237
238 generate_config_file(output_dir, config)?;
240
241 if config.generate_examples {
243 generate_example_benchmarks(output_dir)?;
244 }
245
246 Ok(output_dir.clone())
247}
248
249fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
251 let crate_dir = output_dir.join("bench-mobile");
252 fs::create_dir_all(crate_dir.join("src"))?;
253
254 let crate_name = format!("{}-bench-mobile", project_name);
255
256 let cargo_toml = format!(
260 r#"[package]
261name = "{}"
262version = "0.1.0"
263edition = "2021"
264
265[lib]
266crate-type = ["cdylib", "staticlib", "rlib"]
267
268[dependencies]
269mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
270uniffi = "0.28"
271{} = {{ path = ".." }}
272
273[features]
274default = []
275
276[build-dependencies]
277uniffi = {{ version = "0.28", features = ["build"] }}
278
279# Binary for generating UniFFI bindings (used by mobench build)
280[[bin]]
281name = "uniffi-bindgen"
282path = "src/bin/uniffi-bindgen.rs"
283
284# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
285# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
286# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
287#
288# Add this to your root Cargo.toml:
289# [workspace.dependencies]
290# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
291#
292# Then in each crate that uses rustls:
293# [dependencies]
294# rustls = {{ workspace = true }}
295"#,
296 crate_name, project_name
297 );
298
299 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
300
301 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
303//!
304//! This crate provides the FFI boundary between Rust benchmarks and mobile
305//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
306
307use uniffi;
308
309// Ensure the user crate is linked so benchmark registrations are pulled in.
310extern crate {{USER_CRATE}} as _bench_user_crate;
311
312// Re-export mobench-sdk types with UniFFI annotations
313#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
314pub struct BenchSpec {
315 pub name: String,
316 pub iterations: u32,
317 pub warmup: u32,
318}
319
320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
321pub struct BenchSample {
322 pub duration_ns: u64,
323 pub cpu_time_ms: Option<u64>,
324 pub peak_memory_kb: Option<u64>,
325 pub process_peak_memory_kb: Option<u64>,
326}
327
328#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
329pub struct SemanticPhase {
330 pub name: String,
331 pub duration_ns: u64,
332}
333
334#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
335pub struct HarnessTimelineSpan {
336 pub phase: String,
337 pub start_offset_ns: u64,
338 pub end_offset_ns: u64,
339 pub iteration: Option<u32>,
340}
341
342#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
343pub struct BenchReport {
344 pub spec: BenchSpec,
345 pub samples: Vec<BenchSample>,
346 pub phases: Vec<SemanticPhase>,
347 pub timeline: Vec<HarnessTimelineSpan>,
348}
349
350#[derive(Debug, thiserror::Error, uniffi::Error)]
351#[uniffi(flat_error)]
352pub enum BenchError {
353 #[error("iterations must be greater than zero")]
354 InvalidIterations,
355
356 #[error("unknown benchmark function: {name}")]
357 UnknownFunction { name: String },
358
359 #[error("benchmark execution failed: {reason}")]
360 ExecutionFailed { reason: String },
361}
362
363// Convert from mobench-sdk types
364impl From<mobench_sdk::BenchSpec> for BenchSpec {
365 fn from(spec: mobench_sdk::BenchSpec) -> Self {
366 Self {
367 name: spec.name,
368 iterations: spec.iterations,
369 warmup: spec.warmup,
370 }
371 }
372}
373
374impl From<BenchSpec> for mobench_sdk::BenchSpec {
375 fn from(spec: BenchSpec) -> Self {
376 Self {
377 name: spec.name,
378 iterations: spec.iterations,
379 warmup: spec.warmup,
380 }
381 }
382}
383
384impl From<mobench_sdk::BenchSample> for BenchSample {
385 fn from(sample: mobench_sdk::BenchSample) -> Self {
386 Self {
387 duration_ns: sample.duration_ns,
388 cpu_time_ms: sample.cpu_time_ms,
389 peak_memory_kb: sample.peak_memory_kb,
390 process_peak_memory_kb: sample.process_peak_memory_kb,
391 }
392 }
393}
394
395impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
396 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
397 Self {
398 name: phase.name,
399 duration_ns: phase.duration_ns,
400 }
401 }
402}
403
404impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
405 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
406 Self {
407 phase: span.phase,
408 start_offset_ns: span.start_offset_ns,
409 end_offset_ns: span.end_offset_ns,
410 iteration: span.iteration,
411 }
412 }
413}
414
415impl From<mobench_sdk::RunnerReport> for BenchReport {
416 fn from(report: mobench_sdk::RunnerReport) -> Self {
417 Self {
418 spec: report.spec.into(),
419 samples: report.samples.into_iter().map(Into::into).collect(),
420 phases: report.phases.into_iter().map(Into::into).collect(),
421 timeline: report.timeline.into_iter().map(Into::into).collect(),
422 }
423 }
424}
425
426impl From<mobench_sdk::BenchError> for BenchError {
427 fn from(err: mobench_sdk::BenchError) -> Self {
428 match err {
429 mobench_sdk::BenchError::Runner(runner_err) => {
430 BenchError::ExecutionFailed {
431 reason: runner_err.to_string(),
432 }
433 }
434 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
435 BenchError::UnknownFunction { name }
436 }
437 _ => BenchError::ExecutionFailed {
438 reason: err.to_string(),
439 },
440 }
441 }
442}
443
444/// Runs a benchmark by name with the given specification
445///
446/// This is the main FFI entry point called from mobile platforms.
447#[uniffi::export]
448pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
449 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
450 let report = mobench_sdk::run_benchmark(sdk_spec)?;
451 Ok(report.into())
452}
453
454// Generate UniFFI scaffolding
455uniffi::setup_scaffolding!();
456"#;
457
458 let lib_rs = render_template(
459 lib_rs_template,
460 &[TemplateVar {
461 name: "USER_CRATE",
462 value: project_name.replace('-', "_"),
463 }],
464 );
465 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
466
467 let build_rs = r#"fn main() {
469 uniffi::generate_scaffolding("src/lib.rs").unwrap();
470}
471"#;
472
473 fs::write(crate_dir.join("build.rs"), build_rs)?;
474
475 let bin_dir = crate_dir.join("src/bin");
477 fs::create_dir_all(&bin_dir)?;
478 let uniffi_bindgen_rs = r#"fn main() {
479 uniffi::uniffi_bindgen_main()
480}
481"#;
482 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
483
484 Ok(())
485}
486
487pub fn generate_android_project(
498 output_dir: &Path,
499 project_slug: &str,
500 default_function: &str,
501) -> Result<(), BenchError> {
502 let target_dir = output_dir.join("android");
503 reset_generated_project_dir(&target_dir)?;
504 let library_name = project_slug.replace('-', "_");
505 let project_pascal = to_pascal_case(project_slug);
506 let package_id_component = sanitize_bundle_id_component(project_slug);
509 let package_name = format!("dev.world.{}", package_id_component);
510 let vars = vec![
511 TemplateVar {
512 name: "PROJECT_NAME",
513 value: project_slug.to_string(),
514 },
515 TemplateVar {
516 name: "PROJECT_NAME_PASCAL",
517 value: project_pascal.clone(),
518 },
519 TemplateVar {
520 name: "APP_NAME",
521 value: format!("{} Benchmark", project_pascal),
522 },
523 TemplateVar {
524 name: "PACKAGE_NAME",
525 value: package_name.clone(),
526 },
527 TemplateVar {
528 name: "UNIFFI_NAMESPACE",
529 value: library_name.clone(),
530 },
531 TemplateVar {
532 name: "LIBRARY_NAME",
533 value: library_name,
534 },
535 TemplateVar {
536 name: "DEFAULT_FUNCTION",
537 value: default_function.to_string(),
538 },
539 ];
540 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
541
542 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
545
546 Ok(())
547}
548
549fn collect_preserved_files(
550 root: &Path,
551 current: &Path,
552 preserved: &mut Vec<(PathBuf, Vec<u8>)>,
553) -> Result<(), BenchError> {
554 let mut entries = fs::read_dir(current)?
555 .collect::<Result<Vec<_>, _>>()
556 .map_err(BenchError::Io)?;
557 entries.sort_by_key(|entry| entry.path());
558
559 for entry in entries {
560 let path = entry.path();
561 if path.is_dir() {
562 collect_preserved_files(root, &path, preserved)?;
563 continue;
564 }
565
566 let relative = path.strip_prefix(root).map_err(|e| {
567 BenchError::Build(format!(
568 "Failed to preserve generated resource {:?}: {}",
569 path, e
570 ))
571 })?;
572 preserved.push((relative.to_path_buf(), fs::read(&path)?));
573 }
574
575 Ok(())
576}
577
578fn collect_preserved_ios_resources(
579 target_dir: &Path,
580) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
581 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
582 let mut preserved = Vec::new();
583
584 if resources_dir.exists() {
585 collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
586 }
587
588 Ok(preserved)
589}
590
591fn restore_preserved_ios_resources(
592 target_dir: &Path,
593 preserved_resources: &[(PathBuf, Vec<u8>)],
594) -> Result<(), BenchError> {
595 if preserved_resources.is_empty() {
596 return Ok(());
597 }
598
599 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
600 for (relative, contents) in preserved_resources {
601 let resource_path = resources_dir.join(relative);
602 if let Some(parent) = resource_path.parent() {
603 fs::create_dir_all(parent)?;
604 }
605 fs::write(resource_path, contents)?;
606 }
607
608 Ok(())
609}
610
611fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
612 if target_dir.exists() {
613 fs::remove_dir_all(target_dir).map_err(|e| {
614 BenchError::Build(format!(
615 "Failed to clear existing generated project at {:?}: {}",
616 target_dir, e
617 ))
618 })?;
619 }
620 Ok(())
621}
622
623fn move_kotlin_files_to_package_dir(
633 android_dir: &Path,
634 package_name: &str,
635) -> Result<(), BenchError> {
636 let package_path = package_name.replace('.', "/");
638
639 let main_java_dir = android_dir.join("app/src/main/java");
641 let main_package_dir = main_java_dir.join(&package_path);
642 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
643
644 let test_java_dir = android_dir.join("app/src/androidTest/java");
646 let test_package_dir = test_java_dir.join(&package_path);
647 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
648
649 Ok(())
650}
651
652fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
654 let src_file = src_dir.join(filename);
655 if !src_file.exists() {
656 return Ok(());
658 }
659
660 fs::create_dir_all(dest_dir).map_err(|e| {
662 BenchError::Build(format!(
663 "Failed to create package directory {:?}: {}",
664 dest_dir, e
665 ))
666 })?;
667
668 let dest_file = dest_dir.join(filename);
669
670 fs::copy(&src_file, &dest_file).map_err(|e| {
672 BenchError::Build(format!(
673 "Failed to copy {} to {:?}: {}",
674 filename, dest_file, e
675 ))
676 })?;
677
678 fs::remove_file(&src_file).map_err(|e| {
679 BenchError::Build(format!(
680 "Failed to remove original file {:?}: {}",
681 src_file, e
682 ))
683 })?;
684
685 Ok(())
686}
687
688pub fn generate_ios_project(
701 output_dir: &Path,
702 project_slug: &str,
703 project_pascal: &str,
704 bundle_prefix: &str,
705 default_function: &str,
706) -> Result<(), BenchError> {
707 let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
708 std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
709 .ok()
710 .as_deref(),
711 );
712 generate_ios_project_with_options(
713 output_dir,
714 project_slug,
715 project_pascal,
716 bundle_prefix,
717 default_function,
718 IosProjectOptions {
719 ios_benchmark_timeout_secs,
720 ..IosProjectOptions::default()
721 },
722 )
723}
724
725#[cfg(test)]
726#[allow(dead_code)]
727fn generate_ios_project_with_timeout(
728 output_dir: &Path,
729 project_slug: &str,
730 project_pascal: &str,
731 bundle_prefix: &str,
732 default_function: &str,
733 ios_benchmark_timeout_secs: u64,
734) -> Result<(), BenchError> {
735 generate_ios_project_with_options(
736 output_dir,
737 project_slug,
738 project_pascal,
739 bundle_prefix,
740 default_function,
741 IosProjectOptions {
742 ios_benchmark_timeout_secs,
743 ..IosProjectOptions::default()
744 },
745 )
746}
747
748pub fn generate_ios_project_with_options(
749 output_dir: &Path,
750 project_slug: &str,
751 project_pascal: &str,
752 bundle_prefix: &str,
753 default_function: &str,
754 options: IosProjectOptions,
755) -> Result<(), BenchError> {
756 let runner = resolve_ios_runner(&options.deployment_target, Some(options.runner))?;
757 let target_dir = output_dir.join("ios");
758 let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
759 reset_generated_project_dir(&target_dir)?;
760 let sanitized_bundle_prefix = {
763 let parts: Vec<&str> = bundle_prefix.split('.').collect();
764 parts
765 .iter()
766 .map(|part| sanitize_bundle_id_component(part))
767 .collect::<Vec<_>>()
768 .join(".")
769 };
770 let vars = vec![
774 TemplateVar {
775 name: "DEFAULT_FUNCTION",
776 value: default_function.to_string(),
777 },
778 TemplateVar {
779 name: "PROJECT_NAME_PASCAL",
780 value: project_pascal.to_string(),
781 },
782 TemplateVar {
783 name: "BUNDLE_ID_PREFIX",
784 value: sanitized_bundle_prefix.clone(),
785 },
786 TemplateVar {
787 name: "BUNDLE_ID",
788 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
789 },
790 TemplateVar {
791 name: "LIBRARY_NAME",
792 value: project_slug.replace('-', "_"),
793 },
794 TemplateVar {
795 name: "IOS_BENCHMARK_TIMEOUT_SECS",
796 value: options.ios_benchmark_timeout_secs.to_string(),
797 },
798 TemplateVar {
799 name: "IOS_DEPLOYMENT_TARGET",
800 value: options.deployment_target.to_string(),
801 },
802 TemplateVar {
803 name: "IOS_RUNNER",
804 value: runner.as_str().to_string(),
805 },
806 ];
807 render_ios_dir(&IOS_TEMPLATES, &target_dir, &vars, runner)?;
808 restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
809 Ok(())
810}
811
812fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
813 value
814 .and_then(|raw| raw.parse::<u64>().ok())
815 .filter(|secs| *secs > 0)
816 .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
817}
818
819fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
821 let config_target = match config.target {
822 Target::Ios => "ios",
823 Target::Android | Target::Both => "android",
824 };
825 let config_content = format!(
826 r#"# mobench configuration
827# This file controls how benchmarks are executed on devices.
828
829target = "{}"
830function = "example_fibonacci"
831iterations = 100
832warmup = 10
833device_matrix = "device-matrix.yaml"
834device_tags = ["default"]
835
836[browserstack]
837app_automate_username = "${{BROWSERSTACK_USERNAME}}"
838app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
839project = "{}-benchmarks"
840
841[ios_xcuitest]
842app = "target/ios/BenchRunner.ipa"
843test_suite = "target/ios/BenchRunnerUITests.zip"
844"#,
845 config_target, config.project_name
846 );
847
848 fs::write(output_dir.join("bench-config.toml"), config_content)?;
849
850 Ok(())
851}
852
853fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
855 let examples_dir = output_dir.join("benches");
856 fs::create_dir_all(&examples_dir)?;
857
858 let example_content = r#"//! Example benchmarks
859//!
860//! This file demonstrates how to write benchmarks with mobench-sdk.
861
862use mobench_sdk::benchmark;
863
864/// Simple benchmark example
865#[benchmark]
866fn example_fibonacci() {
867 let result = fibonacci(30);
868 std::hint::black_box(result);
869}
870
871/// Another example with a loop
872#[benchmark]
873fn example_sum() {
874 let mut sum = 0u64;
875 for i in 0..10000 {
876 sum = sum.wrapping_add(i);
877 }
878 std::hint::black_box(sum);
879}
880
881// Helper function (not benchmarked)
882fn fibonacci(n: u32) -> u64 {
883 match n {
884 0 => 0,
885 1 => 1,
886 _ => {
887 let mut a = 0u64;
888 let mut b = 1u64;
889 for _ in 2..=n {
890 let next = a.wrapping_add(b);
891 a = b;
892 b = next;
893 }
894 b
895 }
896 }
897}
898"#;
899
900 fs::write(examples_dir.join("example.rs"), example_content)?;
901
902 Ok(())
903}
904
905const TEMPLATE_EXTENSIONS: &[&str] = &[
907 "gradle",
908 "xml",
909 "kt",
910 "java",
911 "swift",
912 "yml",
913 "yaml",
914 "json",
915 "toml",
916 "md",
917 "txt",
918 "h",
919 "m",
920 "plist",
921 "pbxproj",
922 "xcscheme",
923 "xcworkspacedata",
924 "entitlements",
925 "modulemap",
926];
927
928fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
929 render_dir_filtered(dir, out_root, vars, &|_| false)
930}
931
932fn render_ios_dir(
933 dir: &Dir,
934 out_root: &Path,
935 vars: &[TemplateVar],
936 runner: IosRunner,
937) -> Result<(), BenchError> {
938 render_dir_filtered(dir, out_root, vars, &|path| match runner {
939 IosRunner::Swiftui => {
940 path == Path::new("BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template")
941 }
942 IosRunner::UikitLegacy => {
943 path == Path::new("BenchRunner/BenchRunner/BenchRunnerApp.swift.template")
944 || path == Path::new("BenchRunner/BenchRunner/ContentView.swift.template")
945 }
946 })
947}
948
949fn render_dir_filtered(
950 dir: &Dir,
951 out_root: &Path,
952 vars: &[TemplateVar],
953 skip_file: &dyn Fn(&Path) -> bool,
954) -> Result<(), BenchError> {
955 for entry in dir.entries() {
956 match entry {
957 DirEntry::Dir(sub) => {
958 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
960 continue;
961 }
962 render_dir_filtered(sub, out_root, vars, skip_file)?;
963 }
964 DirEntry::File(file) => {
965 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
966 continue;
967 }
968 if skip_file(file.path()) {
969 continue;
970 }
971 let mut relative = file.path().to_path_buf();
973 let mut contents = file.contents().to_vec();
974
975 let is_explicit_template = relative
977 .extension()
978 .map(|ext| ext == "template")
979 .unwrap_or(false);
980
981 let should_render = is_explicit_template || is_template_file(&relative);
983
984 if is_explicit_template {
985 relative.set_extension("");
987 }
988
989 if should_render && let Ok(text) = std::str::from_utf8(&contents) {
990 let rendered = render_template(text, vars);
991 validate_no_unreplaced_placeholders(&rendered, &relative)?;
993 contents = rendered.into_bytes();
994 }
995
996 let out_path = out_root.join(relative);
997 if let Some(parent) = out_path.parent() {
998 fs::create_dir_all(parent)?;
999 }
1000 fs::write(&out_path, contents)?;
1001 }
1002 }
1003 }
1004 Ok(())
1005}
1006
1007fn is_template_file(path: &Path) -> bool {
1010 if let Some(ext) = path.extension() {
1012 if ext == "template" {
1013 return true;
1014 }
1015 if let Some(ext_str) = ext.to_str() {
1017 return TEMPLATE_EXTENSIONS.contains(&ext_str);
1018 }
1019 }
1020 if let Some(stem) = path.file_stem() {
1022 let stem_path = Path::new(stem);
1023 if let Some(ext) = stem_path.extension()
1024 && let Some(ext_str) = ext.to_str()
1025 {
1026 return TEMPLATE_EXTENSIONS.contains(&ext_str);
1027 }
1028 }
1029 false
1030}
1031
1032fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
1034 let mut pos = 0;
1036 let mut unreplaced = Vec::new();
1037
1038 while let Some(start) = content[pos..].find("{{") {
1039 let abs_start = pos + start;
1040 if let Some(end) = content[abs_start..].find("}}") {
1041 let placeholder = &content[abs_start..abs_start + end + 2];
1042 let var_name = &content[abs_start + 2..abs_start + end];
1044 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
1047 unreplaced.push(placeholder.to_string());
1048 }
1049 pos = abs_start + end + 2;
1050 } else {
1051 break;
1052 }
1053 }
1054
1055 if !unreplaced.is_empty() {
1056 return Err(BenchError::Build(format!(
1057 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
1058 This is a bug in mobench-sdk. Please report it at:\n\
1059 https://github.com/worldcoin/mobile-bench-rs/issues",
1060 file_path, unreplaced
1061 )));
1062 }
1063
1064 Ok(())
1065}
1066
1067fn render_template(input: &str, vars: &[TemplateVar]) -> String {
1068 let mut output = input.to_string();
1069 for var in vars {
1070 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
1071 }
1072 output
1073}
1074
1075pub fn sanitize_bundle_id_component(name: &str) -> String {
1086 name.chars()
1087 .filter(|c| c.is_ascii_alphanumeric())
1088 .collect::<String>()
1089 .to_lowercase()
1090}
1091
1092fn sanitize_package_name(name: &str) -> String {
1093 name.chars()
1094 .map(|c| {
1095 if c.is_ascii_alphanumeric() {
1096 c.to_ascii_lowercase()
1097 } else {
1098 '-'
1099 }
1100 })
1101 .collect::<String>()
1102 .trim_matches('-')
1103 .replace("--", "-")
1104}
1105
1106pub fn to_pascal_case(input: &str) -> String {
1108 input
1109 .split(|c: char| !c.is_ascii_alphanumeric())
1110 .filter(|s| !s.is_empty())
1111 .map(|s| {
1112 let mut chars = s.chars();
1113 let first = chars.next().unwrap().to_ascii_uppercase();
1114 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
1115 format!("{}{}", first, rest)
1116 })
1117 .collect::<String>()
1118}
1119
1120pub fn android_project_exists(output_dir: &Path) -> bool {
1124 let android_dir = output_dir.join("android");
1125 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
1126}
1127
1128pub fn ios_project_exists(output_dir: &Path) -> bool {
1132 output_dir.join("ios/BenchRunner/project.yml").exists()
1133}
1134
1135fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1140 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
1141 let Ok(content) = std::fs::read_to_string(&project_yml) else {
1142 return false;
1143 };
1144 let expected = format!("../{}.xcframework", library_name);
1145 content.contains(&expected)
1146}
1147
1148fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1153 let build_gradle = output_dir.join("android/app/build.gradle");
1154 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
1155 return false;
1156 };
1157 let expected = format!("lib{}.so", library_name);
1158 content.contains(&expected)
1159}
1160
1161pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
1176 let lib_rs = crate_dir.join("src/lib.rs");
1177 if !lib_rs.exists() {
1178 return None;
1179 }
1180
1181 let file = fs::File::open(&lib_rs).ok()?;
1182 let reader = BufReader::new(file);
1183
1184 let mut found_benchmark_attr = false;
1185 let crate_name_normalized = crate_name.replace('-', "_");
1186
1187 for line in reader.lines().map_while(Result::ok) {
1188 let trimmed = line.trim();
1189
1190 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1192 found_benchmark_attr = true;
1193 continue;
1194 }
1195
1196 if found_benchmark_attr {
1198 if let Some(fn_pos) = trimmed.find("fn ") {
1200 let after_fn = &trimmed[fn_pos + 3..];
1201 let fn_name: String = after_fn
1203 .chars()
1204 .take_while(|c| c.is_alphanumeric() || *c == '_')
1205 .collect();
1206
1207 if !fn_name.is_empty() {
1208 return Some(format!("{}::{}", crate_name_normalized, fn_name));
1209 }
1210 }
1211 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1214 found_benchmark_attr = false;
1215 }
1216 }
1217 }
1218
1219 None
1220}
1221
1222pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1236 let lib_rs = crate_dir.join("src/lib.rs");
1237 if !lib_rs.exists() {
1238 return Vec::new();
1239 }
1240
1241 let Ok(file) = fs::File::open(&lib_rs) else {
1242 return Vec::new();
1243 };
1244 let reader = BufReader::new(file);
1245
1246 let mut benchmarks = Vec::new();
1247 let mut found_benchmark_attr = false;
1248 let crate_name_normalized = crate_name.replace('-', "_");
1249
1250 for line in reader.lines().map_while(Result::ok) {
1251 let trimmed = line.trim();
1252
1253 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1255 found_benchmark_attr = true;
1256 continue;
1257 }
1258
1259 if found_benchmark_attr {
1261 if let Some(fn_pos) = trimmed.find("fn ") {
1263 let after_fn = &trimmed[fn_pos + 3..];
1264 let fn_name: String = after_fn
1266 .chars()
1267 .take_while(|c| c.is_alphanumeric() || *c == '_')
1268 .collect();
1269
1270 if !fn_name.is_empty() {
1271 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1272 }
1273 found_benchmark_attr = false;
1274 }
1275 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1278 found_benchmark_attr = false;
1279 }
1280 }
1281 }
1282
1283 benchmarks
1284}
1285
1286pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1298 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1299 let crate_name_normalized = crate_name.replace('-', "_");
1300
1301 let normalized_name = if function_name.contains("::") {
1303 function_name.to_string()
1304 } else {
1305 format!("{}::{}", crate_name_normalized, function_name)
1306 };
1307
1308 benchmarks.iter().any(|b| b == &normalized_name)
1309}
1310
1311pub fn resolve_default_function(
1326 project_root: &Path,
1327 crate_name: &str,
1328 crate_dir: Option<&Path>,
1329) -> String {
1330 let crate_name_normalized = crate_name.replace('-', "_");
1331
1332 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1334 vec![dir.to_path_buf()]
1335 } else {
1336 vec![
1337 project_root.join("bench-mobile"),
1338 project_root.join("crates").join(crate_name),
1339 project_root.to_path_buf(),
1340 ]
1341 };
1342
1343 for dir in &search_dirs {
1345 if dir.join("Cargo.toml").exists()
1346 && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1347 {
1348 return detected;
1349 }
1350 }
1351
1352 format!("{}::example_benchmark", crate_name_normalized)
1354}
1355
1356pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1367 ensure_android_project_with_options(output_dir, crate_name, None, None)
1368}
1369
1370pub fn ensure_android_project_with_options(
1382 output_dir: &Path,
1383 crate_name: &str,
1384 project_root: Option<&Path>,
1385 crate_dir: Option<&Path>,
1386) -> Result<(), BenchError> {
1387 let library_name = crate_name.replace('-', "_");
1388 if android_project_exists(output_dir)
1389 && android_project_matches_library(output_dir, &library_name)
1390 {
1391 return Ok(());
1392 }
1393
1394 println!("Android project not found, generating scaffolding...");
1395 let project_slug = crate_name.replace('-', "_");
1396
1397 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1399 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1400
1401 generate_android_project(output_dir, &project_slug, &default_function)?;
1402 println!(
1403 " Generated Android project at {:?}",
1404 output_dir.join("android")
1405 );
1406 println!(" Default benchmark function: {}", default_function);
1407 Ok(())
1408}
1409
1410pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1421 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1422}
1423
1424pub fn ensure_ios_project_with_options(
1436 output_dir: &Path,
1437 crate_name: &str,
1438 project_root: Option<&Path>,
1439 crate_dir: Option<&Path>,
1440) -> Result<(), BenchError> {
1441 ensure_ios_project_with_project_options(
1442 output_dir,
1443 crate_name,
1444 project_root,
1445 crate_dir,
1446 IosProjectOptions::default(),
1447 )
1448}
1449
1450pub fn ensure_ios_project_with_project_options(
1451 output_dir: &Path,
1452 crate_name: &str,
1453 project_root: Option<&Path>,
1454 crate_dir: Option<&Path>,
1455 options: IosProjectOptions,
1456) -> Result<(), BenchError> {
1457 let library_name = crate_name.replace('-', "_");
1458 let project_exists = ios_project_exists(output_dir);
1459 let project_matches = ios_project_matches_library(output_dir, &library_name);
1460 if project_exists && !project_matches {
1461 println!("Existing iOS scaffolding does not match library, regenerating...");
1462 } else if project_exists {
1463 println!("Refreshing generated iOS scaffolding...");
1464 } else {
1465 println!("iOS project not found, generating scaffolding...");
1466 }
1467
1468 let project_pascal = "BenchRunner";
1470 let library_name = crate_name.replace('-', "_");
1472 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1475 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1476
1477 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1479 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1480
1481 generate_ios_project_with_options(
1482 output_dir,
1483 &library_name,
1484 project_pascal,
1485 &bundle_prefix,
1486 &default_function,
1487 options,
1488 )?;
1489 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1490 println!(" Default benchmark function: {}", default_function);
1491 Ok(())
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496 use super::*;
1497 use std::env;
1498
1499 #[test]
1500 fn test_generate_bench_mobile_crate() {
1501 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1502 fs::create_dir_all(&temp_dir).unwrap();
1503
1504 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1505 assert!(result.is_ok());
1506
1507 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1509 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1510 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1511 let cargo_toml =
1512 fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
1513 assert!(
1514 cargo_toml.contains(
1515 r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
1516 ),
1517 "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
1518 );
1519
1520 fs::remove_dir_all(&temp_dir).ok();
1522 }
1523
1524 #[test]
1525 fn test_generate_android_project_no_unreplaced_placeholders() {
1526 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1527 let _ = fs::remove_dir_all(&temp_dir);
1529 fs::create_dir_all(&temp_dir).unwrap();
1530
1531 let result =
1532 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1533 assert!(
1534 result.is_ok(),
1535 "generate_android_project failed: {:?}",
1536 result.err()
1537 );
1538
1539 let android_dir = temp_dir.join("android");
1541 assert!(android_dir.join("settings.gradle").exists());
1542 assert!(android_dir.join("app/build.gradle").exists());
1543 assert!(
1544 android_dir
1545 .join("app/src/main/AndroidManifest.xml")
1546 .exists()
1547 );
1548 assert!(
1549 android_dir
1550 .join("app/src/main/res/values/strings.xml")
1551 .exists()
1552 );
1553 assert!(
1554 android_dir
1555 .join("app/src/main/res/values/themes.xml")
1556 .exists()
1557 );
1558
1559 let files_to_check = [
1561 "settings.gradle",
1562 "app/build.gradle",
1563 "app/src/main/AndroidManifest.xml",
1564 "app/src/main/res/values/strings.xml",
1565 "app/src/main/res/values/themes.xml",
1566 ];
1567
1568 for file in files_to_check {
1569 let path = android_dir.join(file);
1570 let contents =
1571 fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1572
1573 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1575 assert!(
1576 !has_placeholder,
1577 "File {} contains unreplaced template placeholders: {}",
1578 file, contents
1579 );
1580 }
1581
1582 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1584 assert!(
1585 settings.contains("my-bench-project-android")
1586 || settings.contains("my_bench_project-android"),
1587 "settings.gradle should contain project name"
1588 );
1589
1590 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1591 assert!(
1593 build_gradle.contains("dev.world.mybenchproject"),
1594 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1595 );
1596 assert!(
1597 !build_gradle.contains("testBuildType \"release\""),
1598 "debug builds should be able to produce assembleDebugAndroidTest"
1599 );
1600 assert!(
1601 build_gradle.contains("mobenchTestBuildType"),
1602 "release builds should be able to request assembleReleaseAndroidTest"
1603 );
1604
1605 let manifest =
1606 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1607 assert!(
1608 manifest.contains("Theme.MyBenchProject"),
1609 "AndroidManifest.xml should contain PascalCase theme name"
1610 );
1611
1612 let strings =
1613 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1614 assert!(
1615 strings.contains("Benchmark"),
1616 "strings.xml should contain app name with Benchmark"
1617 );
1618
1619 let main_activity_path =
1622 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1623 assert!(
1624 main_activity_path.exists(),
1625 "MainActivity.kt should be in package directory: {:?}",
1626 main_activity_path
1627 );
1628
1629 let test_activity_path = android_dir
1630 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1631 assert!(
1632 test_activity_path.exists(),
1633 "MainActivityTest.kt should be in package directory: {:?}",
1634 test_activity_path
1635 );
1636
1637 assert!(
1639 !android_dir
1640 .join("app/src/main/java/MainActivity.kt")
1641 .exists(),
1642 "MainActivity.kt should not be in root java directory"
1643 );
1644 assert!(
1645 !android_dir
1646 .join("app/src/androidTest/java/MainActivityTest.kt")
1647 .exists(),
1648 "MainActivityTest.kt should not be in root java directory"
1649 );
1650
1651 fs::remove_dir_all(&temp_dir).ok();
1653 }
1654
1655 #[test]
1656 fn test_generate_android_project_replaces_previous_package_tree() {
1657 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1658 let _ = fs::remove_dir_all(&temp_dir);
1659 fs::create_dir_all(&temp_dir).unwrap();
1660
1661 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1662 .unwrap();
1663 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1664 assert!(
1665 old_package_dir.exists(),
1666 "expected first package tree to exist"
1667 );
1668
1669 generate_android_project(
1670 &temp_dir,
1671 "basic_benchmark",
1672 "basic_benchmark::bench_fibonacci",
1673 )
1674 .unwrap();
1675
1676 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1677 assert!(
1678 new_package_dir.exists(),
1679 "expected new package tree to exist"
1680 );
1681 assert!(
1682 !old_package_dir.exists(),
1683 "old package tree should be removed when regenerating the Android scaffold"
1684 );
1685
1686 fs::remove_dir_all(&temp_dir).ok();
1687 }
1688
1689 #[test]
1690 fn test_is_template_file() {
1691 assert!(is_template_file(Path::new("settings.gradle")));
1692 assert!(is_template_file(Path::new("app/build.gradle")));
1693 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1694 assert!(is_template_file(Path::new("strings.xml")));
1695 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1696 assert!(is_template_file(Path::new("project.yml")));
1697 assert!(is_template_file(Path::new("Info.plist")));
1698 assert!(!is_template_file(Path::new("libfoo.so")));
1699 assert!(!is_template_file(Path::new("image.png")));
1700 }
1701
1702 #[test]
1703 fn test_mobile_templates_read_process_peak_memory_compatibly() {
1704 let android =
1705 include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1706 assert!(
1707 !android.contains("sample.processPeakMemoryKb"),
1708 "Android template should not require generated bindings to expose processPeakMemoryKb"
1709 );
1710 assert!(
1711 !android.contains("it.processPeakMemoryKb"),
1712 "Android template should not require generated bindings to expose processPeakMemoryKb"
1713 );
1714 assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1715 assert!(
1716 !android.contains("sample.cpuTimeMs"),
1717 "Android template should tolerate BenchSample without cpuTimeMs"
1718 );
1719 assert!(
1720 !android.contains("sample.peakMemoryKb"),
1721 "Android template should tolerate BenchSample without peakMemoryKb"
1722 );
1723 assert!(
1724 !android.contains("report.phases"),
1725 "Android template should tolerate BenchReport without phases"
1726 );
1727 assert!(android.contains("ProcessMemorySampler"));
1728 assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1729 assert!(android.contains("/proc/self/smaps_rollup"));
1730 assert!(android.contains("class BenchmarkWorkerService : Service()"));
1731 assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1732 assert!(android.contains("startForegroundService(intent)"));
1733 assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1734 assert!(android.contains("fun isBenchmarkComplete()"));
1735 assert!(android.contains("BENCH_JSON ${json}"));
1736 assert!(android.contains("BENCH_HEARTBEAT_JSON $json"));
1737 assert!(android.contains("BENCH_FAILURE_JSON $encoded"));
1738 assert!(android.contains("getHistoricalProcessExitReasons"));
1739 assert!(android.contains("ApplicationExitInfo.REASON_LOW_MEMORY"));
1740 assert!(android.contains("android_benchmark_timeout_secs"));
1741 assert!(android.contains("android_heartbeat_interval_secs"));
1742 assert!(!android.contains("resultLatch.await"));
1743 assert!(android.contains("memory_process\", \"isolated_worker\""));
1744
1745 let android_test = include_str!(
1746 "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1747 );
1748 assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1749 assert!(android_test.contains("Thread.sleep(pollMs)"));
1750 assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1751 assert!(android_test.contains("activity.isBenchmarkComplete()"));
1752 assert!(android_test.contains("activity.isBenchmarkFailed()"));
1753 assert!(android_test.contains("activity.emitTimeoutFailureFromTest()"));
1754 assert!(android_test.contains("activity.checkWorkerExit()"));
1755 assert!(android_test.contains("Benchmark failed before BENCH_JSON"));
1756
1757 let ios_test = include_str!(
1758 "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1759 );
1760 assert!(
1761 ios_test.contains("\\\"error\\\""),
1762 "iOS XCUITest template should fail when the benchmark report is an error payload"
1763 );
1764
1765 let android_manifest =
1766 include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1767 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1768 assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1769 assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1770 assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1771 assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1772
1773 let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1774 assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1775 assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1776
1777 let ios =
1778 include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1779 assert!(
1780 !ios.contains("sample.processPeakMemoryKb"),
1781 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1782 );
1783 assert!(
1784 !ios.contains(r"\.processPeakMemoryKb"),
1785 "iOS template should not require generated bindings to expose processPeakMemoryKb"
1786 );
1787 assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1788 assert!(ios.contains("return [\n \"name\": name,"));
1789 assert!(
1790 !ios.contains("sample.cpuTimeMs"),
1791 "iOS template should tolerate BenchSample without cpuTimeMs"
1792 );
1793 assert!(
1794 !ios.contains("sample.peakMemoryKb"),
1795 "iOS template should tolerate BenchSample without peakMemoryKb"
1796 );
1797 assert!(
1798 !ios.contains("report.phases"),
1799 "iOS template should tolerate BenchReport without phases"
1800 );
1801 assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1802 assert!(ios.contains("ProcessMemorySampler"));
1803 assert!(ios.contains("currentProcessResidentMemoryKb"));
1804 assert!(ios.contains("task_info("));
1805 assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1806 assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1807 assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1808
1809 let legacy = include_str!(
1810 "../templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template"
1811 );
1812 assert!(legacy.contains("import UIKit"));
1813 assert!(!legacy.contains("import SwiftUI"));
1814 assert!(!legacy.contains("Task.detached"));
1815 assert!(!legacy.contains("Task.sleep"));
1816 assert!(!legacy.contains("MainActor"));
1817 assert!(legacy.contains("DispatchQueue.global(qos: .userInitiated)"));
1818 assert!(legacy.contains("DispatchQueue.main.async"));
1819 assert!(legacy.contains("textColor = .clear"));
1820 assert!(!legacy.contains(".alpha = 0"));
1821 assert!(legacy.contains("benchmarkReport"));
1822 assert!(legacy.contains("benchmarkCompleted"));
1823 assert!(legacy.contains("benchmarkReportJSON"));
1824 assert!(legacy.contains("BENCH_REPORT_JSON_START"));
1825 assert!(legacy.contains("BENCH_REPORT_JSON_END"));
1826 }
1827
1828 #[test]
1829 fn test_ios_deployment_target_and_runner_selection() {
1830 let ios15 = IosDeploymentTarget::parse("15.0").unwrap();
1831 let ios10 = IosDeploymentTarget::parse("10.0").unwrap();
1832
1833 assert_eq!(IosDeploymentTarget::parse("10").unwrap(), ios10);
1834 assert_eq!(
1835 resolve_ios_runner(&ios15, None).unwrap(),
1836 IosRunner::Swiftui
1837 );
1838 assert_eq!(
1839 resolve_ios_runner(&ios10, None).unwrap(),
1840 IosRunner::UikitLegacy
1841 );
1842 assert!(resolve_ios_runner(&ios10, Some(IosRunner::Swiftui)).is_err());
1843 assert_eq!(
1844 resolve_ios_runner(&ios15, Some(IosRunner::UikitLegacy)).unwrap(),
1845 IosRunner::UikitLegacy
1846 );
1847 }
1848
1849 #[test]
1850 fn test_validate_no_unreplaced_placeholders() {
1851 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1853
1854 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1856
1857 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1859 assert!(result.is_err());
1860 let err = result.unwrap_err().to_string();
1861 assert!(err.contains("{{NAME}}"));
1862 }
1863
1864 #[test]
1865 fn test_to_pascal_case() {
1866 assert_eq!(to_pascal_case("my-project"), "MyProject");
1867 assert_eq!(to_pascal_case("my_project"), "MyProject");
1868 assert_eq!(to_pascal_case("myproject"), "Myproject");
1869 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1870 }
1871
1872 #[test]
1873 fn test_detect_default_function_finds_benchmark() {
1874 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1875 let _ = fs::remove_dir_all(&temp_dir);
1876 fs::create_dir_all(temp_dir.join("src")).unwrap();
1877
1878 let lib_content = r#"
1880use mobench_sdk::benchmark;
1881
1882/// Some docs
1883#[benchmark]
1884fn my_benchmark_func() {
1885 // benchmark code
1886}
1887
1888fn helper_func() {}
1889"#;
1890 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1891 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1892
1893 let result = detect_default_function(&temp_dir, "my_crate");
1894 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1895
1896 fs::remove_dir_all(&temp_dir).ok();
1898 }
1899
1900 #[test]
1901 fn test_detect_default_function_no_benchmark() {
1902 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1903 let _ = fs::remove_dir_all(&temp_dir);
1904 fs::create_dir_all(temp_dir.join("src")).unwrap();
1905
1906 let lib_content = r#"
1908fn regular_function() {
1909 // no benchmark here
1910}
1911"#;
1912 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1913
1914 let result = detect_default_function(&temp_dir, "my_crate");
1915 assert!(result.is_none());
1916
1917 fs::remove_dir_all(&temp_dir).ok();
1919 }
1920
1921 #[test]
1922 fn test_detect_default_function_pub_fn() {
1923 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1924 let _ = fs::remove_dir_all(&temp_dir);
1925 fs::create_dir_all(temp_dir.join("src")).unwrap();
1926
1927 let lib_content = r#"
1929#[benchmark]
1930pub fn public_bench() {
1931 // benchmark code
1932}
1933"#;
1934 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1935
1936 let result = detect_default_function(&temp_dir, "test-crate");
1937 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1938
1939 fs::remove_dir_all(&temp_dir).ok();
1941 }
1942
1943 #[test]
1944 fn test_resolve_default_function_fallback() {
1945 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1946 let _ = fs::remove_dir_all(&temp_dir);
1947 fs::create_dir_all(&temp_dir).unwrap();
1948
1949 let result = resolve_default_function(&temp_dir, "my-crate", None);
1951 assert_eq!(result, "my_crate::example_benchmark");
1952
1953 fs::remove_dir_all(&temp_dir).ok();
1955 }
1956
1957 #[test]
1958 fn test_sanitize_bundle_id_component() {
1959 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1961 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1963 assert_eq!(
1965 sanitize_bundle_id_component("my-project_name"),
1966 "myprojectname"
1967 );
1968 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1970 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1972 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1974 assert_eq!(
1976 sanitize_bundle_id_component("My-Complex_Project-123"),
1977 "mycomplexproject123"
1978 );
1979 }
1980
1981 #[test]
1982 fn test_generate_ios_project_bundle_id_not_duplicated() {
1983 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1984 let _ = fs::remove_dir_all(&temp_dir);
1986 fs::create_dir_all(&temp_dir).unwrap();
1987
1988 let crate_name = "bench-mobile";
1990 let bundle_prefix = "dev.world.benchmobile";
1991 let project_pascal = "BenchRunner";
1992
1993 let result = generate_ios_project(
1994 &temp_dir,
1995 crate_name,
1996 project_pascal,
1997 bundle_prefix,
1998 "bench_mobile::test_func",
1999 );
2000 assert!(
2001 result.is_ok(),
2002 "generate_ios_project failed: {:?}",
2003 result.err()
2004 );
2005
2006 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
2008 assert!(project_yml_path.exists(), "project.yml should exist");
2009
2010 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
2012
2013 assert!(
2016 project_yml.contains("dev.world.benchmobile.BenchRunner"),
2017 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
2018 project_yml
2019 );
2020 assert!(
2021 !project_yml.contains("dev.world.benchmobile.benchmobile"),
2022 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
2023 project_yml
2024 );
2025 assert!(
2026 project_yml.contains("embed: false"),
2027 "Static xcframework dependency should be link-only, got:\n{}",
2028 project_yml
2029 );
2030
2031 fs::remove_dir_all(&temp_dir).ok();
2033 }
2034
2035 #[test]
2036 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
2037 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
2038 let _ = fs::remove_dir_all(&temp_dir);
2039 fs::create_dir_all(&temp_dir).unwrap();
2040
2041 generate_ios_project(
2042 &temp_dir,
2043 "bench_mobile",
2044 "BenchRunner",
2045 "dev.world.benchmobile",
2046 "bench_mobile::bench_prepare",
2047 )
2048 .unwrap();
2049
2050 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
2051 fs::create_dir_all(resources_dir.join("nested")).unwrap();
2052 fs::write(
2053 resources_dir.join("bench_spec.json"),
2054 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
2055 )
2056 .unwrap();
2057 fs::write(
2058 resources_dir.join("bench_meta.json"),
2059 r#"{"build_id":"build-123"}"#,
2060 )
2061 .unwrap();
2062 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
2063
2064 generate_ios_project(
2065 &temp_dir,
2066 "bench_mobile",
2067 "BenchRunner",
2068 "dev.world.benchmobile",
2069 "bench_mobile::bench_prepare",
2070 )
2071 .unwrap();
2072
2073 assert_eq!(
2074 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
2075 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
2076 );
2077 assert_eq!(
2078 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
2079 r#"{"build_id":"build-123"}"#
2080 );
2081 assert_eq!(
2082 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
2083 "keep me"
2084 );
2085
2086 fs::remove_dir_all(&temp_dir).ok();
2087 }
2088
2089 #[test]
2090 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
2091 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
2092 let _ = fs::remove_dir_all(&temp_dir);
2093 fs::create_dir_all(&temp_dir).unwrap();
2094
2095 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2096 .expect("initial iOS project generation should succeed");
2097
2098 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
2099 assert!(content_view_path.exists(), "ContentView.swift should exist");
2100
2101 fs::write(&content_view_path, "stale generated content").unwrap();
2102
2103 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2104 .expect("refreshing existing iOS project should succeed");
2105
2106 let refreshed = fs::read_to_string(&content_view_path).unwrap();
2107 assert!(
2108 refreshed.contains("ProfileLaunchOptions"),
2109 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
2110 refreshed
2111 );
2112 assert!(
2113 refreshed.contains("repeatUntilMs"),
2114 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
2115 refreshed
2116 );
2117 assert!(
2118 refreshed.contains("Task.detached(priority: .userInitiated)"),
2119 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
2120 refreshed
2121 );
2122 assert!(
2123 refreshed.contains("await MainActor.run"),
2124 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
2125 refreshed
2126 );
2127
2128 fs::remove_dir_all(&temp_dir).ok();
2129 }
2130
2131 #[test]
2132 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
2133 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
2134 let _ = fs::remove_dir_all(&temp_dir);
2135 fs::create_dir_all(&temp_dir).unwrap();
2136
2137 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2138 .expect("initial iOS project generation should succeed");
2139
2140 let ui_test_path =
2141 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2142 assert!(
2143 ui_test_path.exists(),
2144 "BenchRunnerUITests.swift should exist"
2145 );
2146
2147 fs::write(&ui_test_path, "stale generated content").unwrap();
2148
2149 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2150 .expect("refreshing existing iOS project should succeed");
2151
2152 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
2153 assert!(
2154 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
2155 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
2156 refreshed
2157 );
2158 assert!(
2159 refreshed.contains(
2160 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
2161 ),
2162 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
2163 refreshed
2164 );
2165
2166 fs::remove_dir_all(&temp_dir).ok();
2167 }
2168
2169 #[test]
2170 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
2171 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
2172 let _ = fs::remove_dir_all(&temp_dir);
2173 fs::create_dir_all(&temp_dir).unwrap();
2174
2175 let result = generate_ios_project_with_timeout(
2176 &temp_dir,
2177 "sample_fns",
2178 "BenchRunner",
2179 "dev.world.samplefns",
2180 "sample_fns::example_benchmark",
2181 1200,
2182 );
2183
2184 assert!(result.is_ok(), "generate_ios_project should succeed");
2185
2186 let ui_test_path =
2187 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2188 let contents = fs::read_to_string(&ui_test_path).unwrap();
2189 assert!(
2190 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
2191 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
2192 contents
2193 );
2194
2195 fs::remove_dir_all(&temp_dir).ok();
2196 }
2197
2198 #[test]
2199 fn test_generate_ios_project_uses_configured_deployment_target() {
2200 let temp_dir = env::temp_dir().join("mobench-sdk-ios-deployment-target-test");
2201 let _ = fs::remove_dir_all(&temp_dir);
2202 fs::create_dir_all(&temp_dir).unwrap();
2203
2204 generate_ios_project_with_options(
2205 &temp_dir,
2206 "sample_fns",
2207 "BenchRunner",
2208 "dev.world.samplefns",
2209 "sample_fns::example_benchmark",
2210 IosProjectOptions {
2211 deployment_target: IosDeploymentTarget::parse("10.0").unwrap(),
2212 runner: IosRunner::UikitLegacy,
2213 ios_benchmark_timeout_secs: 300,
2214 },
2215 )
2216 .expect("generate legacy iOS project");
2217
2218 let project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")).unwrap();
2219 assert!(project_yml.contains("deploymentTarget: \"10.0\""));
2220 assert!(!project_yml.contains("deploymentTarget: \"15.0\""));
2221
2222 let runner = fs::read_to_string(
2223 temp_dir.join("ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift"),
2224 )
2225 .unwrap();
2226 assert!(runner.contains("import UIKit"));
2227 assert!(
2228 !temp_dir
2229 .join("ios/BenchRunner/BenchRunner/ContentView.swift")
2230 .exists()
2231 );
2232 assert!(
2233 !temp_dir
2234 .join("ios/BenchRunner/BenchRunner/BenchRunnerApp.swift")
2235 .exists()
2236 );
2237
2238 fs::remove_dir_all(&temp_dir).ok();
2239 }
2240
2241 #[test]
2242 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
2243 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
2244 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
2245 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
2246 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
2247 }
2248
2249 #[test]
2250 fn test_cross_platform_naming_consistency() {
2251 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
2253 let _ = fs::remove_dir_all(&temp_dir);
2254 fs::create_dir_all(&temp_dir).unwrap();
2255
2256 let project_name = "bench-mobile";
2257
2258 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
2260 assert!(
2261 result.is_ok(),
2262 "generate_android_project failed: {:?}",
2263 result.err()
2264 );
2265
2266 let bundle_id_component = sanitize_bundle_id_component(project_name);
2268 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2269 let result = generate_ios_project(
2270 &temp_dir,
2271 &project_name.replace('-', "_"),
2272 "BenchRunner",
2273 &bundle_prefix,
2274 "bench_mobile::test_func",
2275 );
2276 assert!(
2277 result.is_ok(),
2278 "generate_ios_project failed: {:?}",
2279 result.err()
2280 );
2281
2282 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2284 .expect("Failed to read Android build.gradle");
2285
2286 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2288 .expect("Failed to read iOS project.yml");
2289
2290 assert!(
2294 android_build_gradle.contains("dev.world.benchmobile"),
2295 "Android package should be 'dev.world.benchmobile', got:\n{}",
2296 android_build_gradle
2297 );
2298 assert!(
2299 ios_project_yml.contains("dev.world.benchmobile"),
2300 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
2301 ios_project_yml
2302 );
2303
2304 assert!(
2306 !android_build_gradle.contains("dev.world.bench-mobile"),
2307 "Android package should NOT contain hyphens"
2308 );
2309 assert!(
2310 !android_build_gradle.contains("dev.world.bench_mobile"),
2311 "Android package should NOT contain underscores"
2312 );
2313
2314 fs::remove_dir_all(&temp_dir).ok();
2316 }
2317
2318 #[test]
2319 fn test_cross_platform_version_consistency() {
2320 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
2322 let _ = fs::remove_dir_all(&temp_dir);
2323 fs::create_dir_all(&temp_dir).unwrap();
2324
2325 let project_name = "test-project";
2326
2327 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
2329 assert!(
2330 result.is_ok(),
2331 "generate_android_project failed: {:?}",
2332 result.err()
2333 );
2334
2335 let bundle_id_component = sanitize_bundle_id_component(project_name);
2337 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2338 let result = generate_ios_project(
2339 &temp_dir,
2340 &project_name.replace('-', "_"),
2341 "BenchRunner",
2342 &bundle_prefix,
2343 "test_project::test_func",
2344 );
2345 assert!(
2346 result.is_ok(),
2347 "generate_ios_project failed: {:?}",
2348 result.err()
2349 );
2350
2351 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2353 .expect("Failed to read Android build.gradle");
2354
2355 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2357 .expect("Failed to read iOS project.yml");
2358
2359 assert!(
2361 android_build_gradle.contains("versionName \"1.0.0\""),
2362 "Android versionName should be '1.0.0', got:\n{}",
2363 android_build_gradle
2364 );
2365 assert!(
2366 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2367 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2368 ios_project_yml
2369 );
2370
2371 fs::remove_dir_all(&temp_dir).ok();
2373 }
2374
2375 #[test]
2376 fn test_bundle_id_prefix_consistency() {
2377 let test_cases = vec![
2379 ("my-project", "dev.world.myproject"),
2380 ("bench_mobile", "dev.world.benchmobile"),
2381 ("TestApp", "dev.world.testapp"),
2382 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2383 (
2384 "app_with_many_underscores",
2385 "dev.world.appwithmanyunderscores",
2386 ),
2387 ];
2388
2389 for (input, expected_prefix) in test_cases {
2390 let sanitized = sanitize_bundle_id_component(input);
2391 let full_prefix = format!("dev.world.{}", sanitized);
2392 assert_eq!(
2393 full_prefix, expected_prefix,
2394 "For input '{}', expected '{}' but got '{}'",
2395 input, expected_prefix, full_prefix
2396 );
2397 }
2398 }
2399}