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");
15const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
16
17#[derive(Debug, Clone)]
19pub struct TemplateVar {
20 pub name: &'static str,
21 pub value: String,
22}
23
24pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
41 let output_dir = &config.output_dir;
42 let project_slug = sanitize_package_name(&config.project_name);
43 let project_pascal = to_pascal_case(&project_slug);
44 let bundle_id_component = sanitize_bundle_id_component(&project_slug);
46 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
47
48 fs::create_dir_all(output_dir)?;
50
51 generate_bench_mobile_crate(output_dir, &project_slug)?;
53
54 let default_function = "example_fibonacci";
57
58 match config.target {
60 Target::Android => {
61 generate_android_project(output_dir, &project_slug, default_function)?;
62 }
63 Target::Ios => {
64 generate_ios_project(
65 output_dir,
66 &project_slug,
67 &project_pascal,
68 &bundle_prefix,
69 default_function,
70 )?;
71 }
72 Target::Both => {
73 generate_android_project(output_dir, &project_slug, default_function)?;
74 generate_ios_project(
75 output_dir,
76 &project_slug,
77 &project_pascal,
78 &bundle_prefix,
79 default_function,
80 )?;
81 }
82 }
83
84 generate_config_file(output_dir, config)?;
86
87 if config.generate_examples {
89 generate_example_benchmarks(output_dir)?;
90 }
91
92 Ok(output_dir.clone())
93}
94
95fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
97 let crate_dir = output_dir.join("bench-mobile");
98 fs::create_dir_all(crate_dir.join("src"))?;
99
100 let crate_name = format!("{}-bench-mobile", project_name);
101
102 let cargo_toml = format!(
106 r#"[package]
107name = "{}"
108version = "0.1.0"
109edition = "2021"
110
111[lib]
112crate-type = ["cdylib", "staticlib", "rlib"]
113
114[dependencies]
115mobench-sdk = {{ path = ".." }}
116uniffi = "0.28"
117{} = {{ path = ".." }}
118
119[features]
120default = []
121
122[build-dependencies]
123uniffi = {{ version = "0.28", features = ["build"] }}
124
125# Binary for generating UniFFI bindings (used by mobench build)
126[[bin]]
127name = "uniffi-bindgen"
128path = "src/bin/uniffi-bindgen.rs"
129
130# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
131# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
132# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
133#
134# Add this to your root Cargo.toml:
135# [workspace.dependencies]
136# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
137#
138# Then in each crate that uses rustls:
139# [dependencies]
140# rustls = {{ workspace = true }}
141"#,
142 crate_name, project_name
143 );
144
145 fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
146
147 let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
149//!
150//! This crate provides the FFI boundary between Rust benchmarks and mobile
151//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
152
153use uniffi;
154
155// Ensure the user crate is linked so benchmark registrations are pulled in.
156extern crate {{USER_CRATE}} as _bench_user_crate;
157
158// Re-export mobench-sdk types with UniFFI annotations
159#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
160pub struct BenchSpec {
161 pub name: String,
162 pub iterations: u32,
163 pub warmup: u32,
164}
165
166#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
167pub struct BenchSample {
168 pub duration_ns: u64,
169}
170
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
172pub struct SemanticPhase {
173 pub name: String,
174 pub duration_ns: u64,
175}
176
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
178pub struct HarnessTimelineSpan {
179 pub phase: String,
180 pub start_offset_ns: u64,
181 pub end_offset_ns: u64,
182 pub iteration: Option<u32>,
183}
184
185#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
186pub struct BenchReport {
187 pub spec: BenchSpec,
188 pub samples: Vec<BenchSample>,
189 pub phases: Vec<SemanticPhase>,
190 pub timeline: Vec<HarnessTimelineSpan>,
191}
192
193#[derive(Debug, thiserror::Error, uniffi::Error)]
194#[uniffi(flat_error)]
195pub enum BenchError {
196 #[error("iterations must be greater than zero")]
197 InvalidIterations,
198
199 #[error("unknown benchmark function: {name}")]
200 UnknownFunction { name: String },
201
202 #[error("benchmark execution failed: {reason}")]
203 ExecutionFailed { reason: String },
204}
205
206// Convert from mobench-sdk types
207impl From<mobench_sdk::BenchSpec> for BenchSpec {
208 fn from(spec: mobench_sdk::BenchSpec) -> Self {
209 Self {
210 name: spec.name,
211 iterations: spec.iterations,
212 warmup: spec.warmup,
213 }
214 }
215}
216
217impl From<BenchSpec> for mobench_sdk::BenchSpec {
218 fn from(spec: BenchSpec) -> Self {
219 Self {
220 name: spec.name,
221 iterations: spec.iterations,
222 warmup: spec.warmup,
223 }
224 }
225}
226
227impl From<mobench_sdk::BenchSample> for BenchSample {
228 fn from(sample: mobench_sdk::BenchSample) -> Self {
229 Self {
230 duration_ns: sample.duration_ns,
231 }
232 }
233}
234
235impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
236 fn from(phase: mobench_sdk::SemanticPhase) -> Self {
237 Self {
238 name: phase.name,
239 duration_ns: phase.duration_ns,
240 }
241 }
242}
243
244impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
245 fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
246 Self {
247 phase: span.phase,
248 start_offset_ns: span.start_offset_ns,
249 end_offset_ns: span.end_offset_ns,
250 iteration: span.iteration,
251 }
252 }
253}
254
255impl From<mobench_sdk::RunnerReport> for BenchReport {
256 fn from(report: mobench_sdk::RunnerReport) -> Self {
257 Self {
258 spec: report.spec.into(),
259 samples: report.samples.into_iter().map(Into::into).collect(),
260 phases: report.phases.into_iter().map(Into::into).collect(),
261 timeline: report.timeline.into_iter().map(Into::into).collect(),
262 }
263 }
264}
265
266impl From<mobench_sdk::BenchError> for BenchError {
267 fn from(err: mobench_sdk::BenchError) -> Self {
268 match err {
269 mobench_sdk::BenchError::Runner(runner_err) => {
270 BenchError::ExecutionFailed {
271 reason: runner_err.to_string(),
272 }
273 }
274 mobench_sdk::BenchError::UnknownFunction(name, _available) => {
275 BenchError::UnknownFunction { name }
276 }
277 _ => BenchError::ExecutionFailed {
278 reason: err.to_string(),
279 },
280 }
281 }
282}
283
284/// Runs a benchmark by name with the given specification
285///
286/// This is the main FFI entry point called from mobile platforms.
287#[uniffi::export]
288pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
289 let sdk_spec: mobench_sdk::BenchSpec = spec.into();
290 let report = mobench_sdk::run_benchmark(sdk_spec)?;
291 Ok(report.into())
292}
293
294// Generate UniFFI scaffolding
295uniffi::setup_scaffolding!();
296"#;
297
298 let lib_rs = render_template(
299 lib_rs_template,
300 &[TemplateVar {
301 name: "USER_CRATE",
302 value: project_name.replace('-', "_"),
303 }],
304 );
305 fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
306
307 let build_rs = r#"fn main() {
309 uniffi::generate_scaffolding("src/lib.rs").unwrap();
310}
311"#;
312
313 fs::write(crate_dir.join("build.rs"), build_rs)?;
314
315 let bin_dir = crate_dir.join("src/bin");
317 fs::create_dir_all(&bin_dir)?;
318 let uniffi_bindgen_rs = r#"fn main() {
319 uniffi::uniffi_bindgen_main()
320}
321"#;
322 fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
323
324 Ok(())
325}
326
327pub fn generate_android_project(
338 output_dir: &Path,
339 project_slug: &str,
340 default_function: &str,
341) -> Result<(), BenchError> {
342 let target_dir = output_dir.join("android");
343 reset_generated_project_dir(&target_dir)?;
344 let library_name = project_slug.replace('-', "_");
345 let project_pascal = to_pascal_case(project_slug);
346 let package_id_component = sanitize_bundle_id_component(project_slug);
349 let package_name = format!("dev.world.{}", package_id_component);
350 let vars = vec![
351 TemplateVar {
352 name: "PROJECT_NAME",
353 value: project_slug.to_string(),
354 },
355 TemplateVar {
356 name: "PROJECT_NAME_PASCAL",
357 value: project_pascal.clone(),
358 },
359 TemplateVar {
360 name: "APP_NAME",
361 value: format!("{} Benchmark", project_pascal),
362 },
363 TemplateVar {
364 name: "PACKAGE_NAME",
365 value: package_name.clone(),
366 },
367 TemplateVar {
368 name: "UNIFFI_NAMESPACE",
369 value: library_name.clone(),
370 },
371 TemplateVar {
372 name: "LIBRARY_NAME",
373 value: library_name,
374 },
375 TemplateVar {
376 name: "DEFAULT_FUNCTION",
377 value: default_function.to_string(),
378 },
379 ];
380 render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
381
382 move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
385
386 Ok(())
387}
388
389fn collect_preserved_files(
390 root: &Path,
391 current: &Path,
392 preserved: &mut Vec<(PathBuf, Vec<u8>)>,
393) -> Result<(), BenchError> {
394 let mut entries = fs::read_dir(current)?
395 .collect::<Result<Vec<_>, _>>()
396 .map_err(BenchError::Io)?;
397 entries.sort_by_key(|entry| entry.path());
398
399 for entry in entries {
400 let path = entry.path();
401 if path.is_dir() {
402 collect_preserved_files(root, &path, preserved)?;
403 continue;
404 }
405
406 let relative = path.strip_prefix(root).map_err(|e| {
407 BenchError::Build(format!(
408 "Failed to preserve generated resource {:?}: {}",
409 path, e
410 ))
411 })?;
412 preserved.push((relative.to_path_buf(), fs::read(&path)?));
413 }
414
415 Ok(())
416}
417
418fn collect_preserved_ios_resources(
419 target_dir: &Path,
420) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
421 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
422 let mut preserved = Vec::new();
423
424 if resources_dir.exists() {
425 collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
426 }
427
428 Ok(preserved)
429}
430
431fn restore_preserved_ios_resources(
432 target_dir: &Path,
433 preserved_resources: &[(PathBuf, Vec<u8>)],
434) -> Result<(), BenchError> {
435 if preserved_resources.is_empty() {
436 return Ok(());
437 }
438
439 let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
440 for (relative, contents) in preserved_resources {
441 let resource_path = resources_dir.join(relative);
442 if let Some(parent) = resource_path.parent() {
443 fs::create_dir_all(parent)?;
444 }
445 fs::write(resource_path, contents)?;
446 }
447
448 Ok(())
449}
450
451fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
452 if target_dir.exists() {
453 fs::remove_dir_all(target_dir).map_err(|e| {
454 BenchError::Build(format!(
455 "Failed to clear existing generated project at {:?}: {}",
456 target_dir, e
457 ))
458 })?;
459 }
460 Ok(())
461}
462
463fn move_kotlin_files_to_package_dir(
473 android_dir: &Path,
474 package_name: &str,
475) -> Result<(), BenchError> {
476 let package_path = package_name.replace('.', "/");
478
479 let main_java_dir = android_dir.join("app/src/main/java");
481 let main_package_dir = main_java_dir.join(&package_path);
482 move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
483
484 let test_java_dir = android_dir.join("app/src/androidTest/java");
486 let test_package_dir = test_java_dir.join(&package_path);
487 move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
488
489 Ok(())
490}
491
492fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
494 let src_file = src_dir.join(filename);
495 if !src_file.exists() {
496 return Ok(());
498 }
499
500 fs::create_dir_all(dest_dir).map_err(|e| {
502 BenchError::Build(format!(
503 "Failed to create package directory {:?}: {}",
504 dest_dir, e
505 ))
506 })?;
507
508 let dest_file = dest_dir.join(filename);
509
510 fs::copy(&src_file, &dest_file).map_err(|e| {
512 BenchError::Build(format!(
513 "Failed to copy {} to {:?}: {}",
514 filename, dest_file, e
515 ))
516 })?;
517
518 fs::remove_file(&src_file).map_err(|e| {
519 BenchError::Build(format!(
520 "Failed to remove original file {:?}: {}",
521 src_file, e
522 ))
523 })?;
524
525 Ok(())
526}
527
528pub fn generate_ios_project(
541 output_dir: &Path,
542 project_slug: &str,
543 project_pascal: &str,
544 bundle_prefix: &str,
545 default_function: &str,
546) -> Result<(), BenchError> {
547 let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
548 std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
549 .ok()
550 .as_deref(),
551 );
552 generate_ios_project_with_timeout(
553 output_dir,
554 project_slug,
555 project_pascal,
556 bundle_prefix,
557 default_function,
558 ios_benchmark_timeout_secs,
559 )
560}
561
562fn generate_ios_project_with_timeout(
563 output_dir: &Path,
564 project_slug: &str,
565 project_pascal: &str,
566 bundle_prefix: &str,
567 default_function: &str,
568 ios_benchmark_timeout_secs: u64,
569) -> Result<(), BenchError> {
570 let target_dir = output_dir.join("ios");
571 let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
572 reset_generated_project_dir(&target_dir)?;
573 let sanitized_bundle_prefix = {
576 let parts: Vec<&str> = bundle_prefix.split('.').collect();
577 parts
578 .iter()
579 .map(|part| sanitize_bundle_id_component(part))
580 .collect::<Vec<_>>()
581 .join(".")
582 };
583 let vars = vec![
587 TemplateVar {
588 name: "DEFAULT_FUNCTION",
589 value: default_function.to_string(),
590 },
591 TemplateVar {
592 name: "PROJECT_NAME_PASCAL",
593 value: project_pascal.to_string(),
594 },
595 TemplateVar {
596 name: "BUNDLE_ID_PREFIX",
597 value: sanitized_bundle_prefix.clone(),
598 },
599 TemplateVar {
600 name: "BUNDLE_ID",
601 value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
602 },
603 TemplateVar {
604 name: "LIBRARY_NAME",
605 value: project_slug.replace('-', "_"),
606 },
607 TemplateVar {
608 name: "IOS_BENCHMARK_TIMEOUT_SECS",
609 value: ios_benchmark_timeout_secs.to_string(),
610 },
611 ];
612 render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
613 restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
614 Ok(())
615}
616
617fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
618 value
619 .and_then(|raw| raw.parse::<u64>().ok())
620 .filter(|secs| *secs > 0)
621 .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
622}
623
624fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
626 let config_target = match config.target {
627 Target::Ios => "ios",
628 Target::Android | Target::Both => "android",
629 };
630 let config_content = format!(
631 r#"# mobench configuration
632# This file controls how benchmarks are executed on devices.
633
634target = "{}"
635function = "example_fibonacci"
636iterations = 100
637warmup = 10
638device_matrix = "device-matrix.yaml"
639device_tags = ["default"]
640
641[browserstack]
642app_automate_username = "${{BROWSERSTACK_USERNAME}}"
643app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
644project = "{}-benchmarks"
645
646[ios_xcuitest]
647app = "target/ios/BenchRunner.ipa"
648test_suite = "target/ios/BenchRunnerUITests.zip"
649"#,
650 config_target, config.project_name
651 );
652
653 fs::write(output_dir.join("bench-config.toml"), config_content)?;
654
655 Ok(())
656}
657
658fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
660 let examples_dir = output_dir.join("benches");
661 fs::create_dir_all(&examples_dir)?;
662
663 let example_content = r#"//! Example benchmarks
664//!
665//! This file demonstrates how to write benchmarks with mobench-sdk.
666
667use mobench_sdk::benchmark;
668
669/// Simple benchmark example
670#[benchmark]
671fn example_fibonacci() {
672 let result = fibonacci(30);
673 std::hint::black_box(result);
674}
675
676/// Another example with a loop
677#[benchmark]
678fn example_sum() {
679 let mut sum = 0u64;
680 for i in 0..10000 {
681 sum = sum.wrapping_add(i);
682 }
683 std::hint::black_box(sum);
684}
685
686// Helper function (not benchmarked)
687fn fibonacci(n: u32) -> u64 {
688 match n {
689 0 => 0,
690 1 => 1,
691 _ => {
692 let mut a = 0u64;
693 let mut b = 1u64;
694 for _ in 2..=n {
695 let next = a.wrapping_add(b);
696 a = b;
697 b = next;
698 }
699 b
700 }
701 }
702}
703"#;
704
705 fs::write(examples_dir.join("example.rs"), example_content)?;
706
707 Ok(())
708}
709
710const TEMPLATE_EXTENSIONS: &[&str] = &[
712 "gradle",
713 "xml",
714 "kt",
715 "java",
716 "swift",
717 "yml",
718 "yaml",
719 "json",
720 "toml",
721 "md",
722 "txt",
723 "h",
724 "m",
725 "plist",
726 "pbxproj",
727 "xcscheme",
728 "xcworkspacedata",
729 "entitlements",
730 "modulemap",
731];
732
733fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
734 for entry in dir.entries() {
735 match entry {
736 DirEntry::Dir(sub) => {
737 if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
739 continue;
740 }
741 render_dir(sub, out_root, vars)?;
742 }
743 DirEntry::File(file) => {
744 if file.path().components().any(|c| c.as_os_str() == ".gradle") {
745 continue;
746 }
747 let mut relative = file.path().to_path_buf();
749 let mut contents = file.contents().to_vec();
750
751 let is_explicit_template = relative
753 .extension()
754 .map(|ext| ext == "template")
755 .unwrap_or(false);
756
757 let should_render = is_explicit_template || is_template_file(&relative);
759
760 if is_explicit_template {
761 relative.set_extension("");
763 }
764
765 if should_render {
766 if let Ok(text) = std::str::from_utf8(&contents) {
767 let rendered = render_template(text, vars);
768 validate_no_unreplaced_placeholders(&rendered, &relative)?;
770 contents = rendered.into_bytes();
771 }
772 }
773
774 let out_path = out_root.join(relative);
775 if let Some(parent) = out_path.parent() {
776 fs::create_dir_all(parent)?;
777 }
778 fs::write(&out_path, contents)?;
779 }
780 }
781 }
782 Ok(())
783}
784
785fn is_template_file(path: &Path) -> bool {
788 if let Some(ext) = path.extension() {
790 if ext == "template" {
791 return true;
792 }
793 if let Some(ext_str) = ext.to_str() {
795 return TEMPLATE_EXTENSIONS.contains(&ext_str);
796 }
797 }
798 if let Some(stem) = path.file_stem() {
800 let stem_path = Path::new(stem);
801 if let Some(ext) = stem_path.extension() {
802 if let Some(ext_str) = ext.to_str() {
803 return TEMPLATE_EXTENSIONS.contains(&ext_str);
804 }
805 }
806 }
807 false
808}
809
810fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
812 let mut pos = 0;
814 let mut unreplaced = Vec::new();
815
816 while let Some(start) = content[pos..].find("{{") {
817 let abs_start = pos + start;
818 if let Some(end) = content[abs_start..].find("}}") {
819 let placeholder = &content[abs_start..abs_start + end + 2];
820 let var_name = &content[abs_start + 2..abs_start + end];
822 if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
825 unreplaced.push(placeholder.to_string());
826 }
827 pos = abs_start + end + 2;
828 } else {
829 break;
830 }
831 }
832
833 if !unreplaced.is_empty() {
834 return Err(BenchError::Build(format!(
835 "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
836 This is a bug in mobench-sdk. Please report it at:\n\
837 https://github.com/worldcoin/mobile-bench-rs/issues",
838 file_path, unreplaced
839 )));
840 }
841
842 Ok(())
843}
844
845fn render_template(input: &str, vars: &[TemplateVar]) -> String {
846 let mut output = input.to_string();
847 for var in vars {
848 output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
849 }
850 output
851}
852
853pub fn sanitize_bundle_id_component(name: &str) -> String {
864 name.chars()
865 .filter(|c| c.is_ascii_alphanumeric())
866 .collect::<String>()
867 .to_lowercase()
868}
869
870fn sanitize_package_name(name: &str) -> String {
871 name.chars()
872 .map(|c| {
873 if c.is_ascii_alphanumeric() {
874 c.to_ascii_lowercase()
875 } else {
876 '-'
877 }
878 })
879 .collect::<String>()
880 .trim_matches('-')
881 .replace("--", "-")
882}
883
884pub fn to_pascal_case(input: &str) -> String {
886 input
887 .split(|c: char| !c.is_ascii_alphanumeric())
888 .filter(|s| !s.is_empty())
889 .map(|s| {
890 let mut chars = s.chars();
891 let first = chars.next().unwrap().to_ascii_uppercase();
892 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
893 format!("{}{}", first, rest)
894 })
895 .collect::<String>()
896}
897
898pub fn android_project_exists(output_dir: &Path) -> bool {
902 let android_dir = output_dir.join("android");
903 android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
904}
905
906pub fn ios_project_exists(output_dir: &Path) -> bool {
910 output_dir.join("ios/BenchRunner/project.yml").exists()
911}
912
913fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
918 let project_yml = output_dir.join("ios/BenchRunner/project.yml");
919 let Ok(content) = std::fs::read_to_string(&project_yml) else {
920 return false;
921 };
922 let expected = format!("../{}.xcframework", library_name);
923 content.contains(&expected)
924}
925
926fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
931 let build_gradle = output_dir.join("android/app/build.gradle");
932 let Ok(content) = std::fs::read_to_string(&build_gradle) else {
933 return false;
934 };
935 let expected = format!("lib{}.so", library_name);
936 content.contains(&expected)
937}
938
939pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
954 let lib_rs = crate_dir.join("src/lib.rs");
955 if !lib_rs.exists() {
956 return None;
957 }
958
959 let file = fs::File::open(&lib_rs).ok()?;
960 let reader = BufReader::new(file);
961
962 let mut found_benchmark_attr = false;
963 let crate_name_normalized = crate_name.replace('-', "_");
964
965 for line in reader.lines().map_while(Result::ok) {
966 let trimmed = line.trim();
967
968 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
970 found_benchmark_attr = true;
971 continue;
972 }
973
974 if found_benchmark_attr {
976 if let Some(fn_pos) = trimmed.find("fn ") {
978 let after_fn = &trimmed[fn_pos + 3..];
979 let fn_name: String = after_fn
981 .chars()
982 .take_while(|c| c.is_alphanumeric() || *c == '_')
983 .collect();
984
985 if !fn_name.is_empty() {
986 return Some(format!("{}::{}", crate_name_normalized, fn_name));
987 }
988 }
989 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
992 found_benchmark_attr = false;
993 }
994 }
995 }
996
997 None
998}
999
1000pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1014 let lib_rs = crate_dir.join("src/lib.rs");
1015 if !lib_rs.exists() {
1016 return Vec::new();
1017 }
1018
1019 let Ok(file) = fs::File::open(&lib_rs) else {
1020 return Vec::new();
1021 };
1022 let reader = BufReader::new(file);
1023
1024 let mut benchmarks = Vec::new();
1025 let mut found_benchmark_attr = false;
1026 let crate_name_normalized = crate_name.replace('-', "_");
1027
1028 for line in reader.lines().map_while(Result::ok) {
1029 let trimmed = line.trim();
1030
1031 if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1033 found_benchmark_attr = true;
1034 continue;
1035 }
1036
1037 if found_benchmark_attr {
1039 if let Some(fn_pos) = trimmed.find("fn ") {
1041 let after_fn = &trimmed[fn_pos + 3..];
1042 let fn_name: String = after_fn
1044 .chars()
1045 .take_while(|c| c.is_alphanumeric() || *c == '_')
1046 .collect();
1047
1048 if !fn_name.is_empty() {
1049 benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1050 }
1051 found_benchmark_attr = false;
1052 }
1053 if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1056 found_benchmark_attr = false;
1057 }
1058 }
1059 }
1060
1061 benchmarks
1062}
1063
1064pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1076 let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1077 let crate_name_normalized = crate_name.replace('-', "_");
1078
1079 let normalized_name = if function_name.contains("::") {
1081 function_name.to_string()
1082 } else {
1083 format!("{}::{}", crate_name_normalized, function_name)
1084 };
1085
1086 benchmarks.iter().any(|b| b == &normalized_name)
1087}
1088
1089pub fn resolve_default_function(
1104 project_root: &Path,
1105 crate_name: &str,
1106 crate_dir: Option<&Path>,
1107) -> String {
1108 let crate_name_normalized = crate_name.replace('-', "_");
1109
1110 let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1112 vec![dir.to_path_buf()]
1113 } else {
1114 vec![
1115 project_root.join("bench-mobile"),
1116 project_root.join("crates").join(crate_name),
1117 project_root.to_path_buf(),
1118 ]
1119 };
1120
1121 for dir in &search_dirs {
1123 if dir.join("Cargo.toml").exists() {
1124 if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
1125 return detected;
1126 }
1127 }
1128 }
1129
1130 format!("{}::example_benchmark", crate_name_normalized)
1132}
1133
1134pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1145 ensure_android_project_with_options(output_dir, crate_name, None, None)
1146}
1147
1148pub fn ensure_android_project_with_options(
1160 output_dir: &Path,
1161 crate_name: &str,
1162 project_root: Option<&Path>,
1163 crate_dir: Option<&Path>,
1164) -> Result<(), BenchError> {
1165 let library_name = crate_name.replace('-', "_");
1166 if android_project_exists(output_dir)
1167 && android_project_matches_library(output_dir, &library_name)
1168 {
1169 return Ok(());
1170 }
1171
1172 println!("Android project not found, generating scaffolding...");
1173 let project_slug = crate_name.replace('-', "_");
1174
1175 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1177 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1178
1179 generate_android_project(output_dir, &project_slug, &default_function)?;
1180 println!(
1181 " Generated Android project at {:?}",
1182 output_dir.join("android")
1183 );
1184 println!(" Default benchmark function: {}", default_function);
1185 Ok(())
1186}
1187
1188pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1199 ensure_ios_project_with_options(output_dir, crate_name, None, None)
1200}
1201
1202pub fn ensure_ios_project_with_options(
1214 output_dir: &Path,
1215 crate_name: &str,
1216 project_root: Option<&Path>,
1217 crate_dir: Option<&Path>,
1218) -> Result<(), BenchError> {
1219 let library_name = crate_name.replace('-', "_");
1220 let project_exists = ios_project_exists(output_dir);
1221 let project_matches = ios_project_matches_library(output_dir, &library_name);
1222 if project_exists && !project_matches {
1223 println!("Existing iOS scaffolding does not match library, regenerating...");
1224 } else if project_exists {
1225 println!("Refreshing generated iOS scaffolding...");
1226 } else {
1227 println!("iOS project not found, generating scaffolding...");
1228 }
1229
1230 let project_pascal = "BenchRunner";
1232 let library_name = crate_name.replace('-', "_");
1234 let bundle_id_component = sanitize_bundle_id_component(crate_name);
1237 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1238
1239 let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1241 let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1242
1243 generate_ios_project(
1244 output_dir,
1245 &library_name,
1246 project_pascal,
1247 &bundle_prefix,
1248 &default_function,
1249 )?;
1250 println!(" Generated iOS project at {:?}", output_dir.join("ios"));
1251 println!(" Default benchmark function: {}", default_function);
1252 Ok(())
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use std::env;
1259
1260 #[test]
1261 fn test_generate_bench_mobile_crate() {
1262 let temp_dir = env::temp_dir().join("mobench-sdk-test");
1263 fs::create_dir_all(&temp_dir).unwrap();
1264
1265 let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1266 assert!(result.is_ok());
1267
1268 assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1270 assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1271 assert!(temp_dir.join("bench-mobile/build.rs").exists());
1272
1273 fs::remove_dir_all(&temp_dir).ok();
1275 }
1276
1277 #[test]
1278 fn test_generate_android_project_no_unreplaced_placeholders() {
1279 let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1280 let _ = fs::remove_dir_all(&temp_dir);
1282 fs::create_dir_all(&temp_dir).unwrap();
1283
1284 let result =
1285 generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1286 assert!(
1287 result.is_ok(),
1288 "generate_android_project failed: {:?}",
1289 result.err()
1290 );
1291
1292 let android_dir = temp_dir.join("android");
1294 assert!(android_dir.join("settings.gradle").exists());
1295 assert!(android_dir.join("app/build.gradle").exists());
1296 assert!(
1297 android_dir
1298 .join("app/src/main/AndroidManifest.xml")
1299 .exists()
1300 );
1301 assert!(
1302 android_dir
1303 .join("app/src/main/res/values/strings.xml")
1304 .exists()
1305 );
1306 assert!(
1307 android_dir
1308 .join("app/src/main/res/values/themes.xml")
1309 .exists()
1310 );
1311
1312 let files_to_check = [
1314 "settings.gradle",
1315 "app/build.gradle",
1316 "app/src/main/AndroidManifest.xml",
1317 "app/src/main/res/values/strings.xml",
1318 "app/src/main/res/values/themes.xml",
1319 ];
1320
1321 for file in files_to_check {
1322 let path = android_dir.join(file);
1323 let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1324
1325 let has_placeholder = contents.contains("{{") && contents.contains("}}");
1327 assert!(
1328 !has_placeholder,
1329 "File {} contains unreplaced template placeholders: {}",
1330 file, contents
1331 );
1332 }
1333
1334 let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1336 assert!(
1337 settings.contains("my-bench-project-android")
1338 || settings.contains("my_bench_project-android"),
1339 "settings.gradle should contain project name"
1340 );
1341
1342 let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1343 assert!(
1345 build_gradle.contains("dev.world.mybenchproject"),
1346 "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1347 );
1348
1349 let manifest =
1350 fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1351 assert!(
1352 manifest.contains("Theme.MyBenchProject"),
1353 "AndroidManifest.xml should contain PascalCase theme name"
1354 );
1355
1356 let strings =
1357 fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1358 assert!(
1359 strings.contains("Benchmark"),
1360 "strings.xml should contain app name with Benchmark"
1361 );
1362
1363 let main_activity_path =
1366 android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1367 assert!(
1368 main_activity_path.exists(),
1369 "MainActivity.kt should be in package directory: {:?}",
1370 main_activity_path
1371 );
1372
1373 let test_activity_path = android_dir
1374 .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1375 assert!(
1376 test_activity_path.exists(),
1377 "MainActivityTest.kt should be in package directory: {:?}",
1378 test_activity_path
1379 );
1380
1381 assert!(
1383 !android_dir
1384 .join("app/src/main/java/MainActivity.kt")
1385 .exists(),
1386 "MainActivity.kt should not be in root java directory"
1387 );
1388 assert!(
1389 !android_dir
1390 .join("app/src/androidTest/java/MainActivityTest.kt")
1391 .exists(),
1392 "MainActivityTest.kt should not be in root java directory"
1393 );
1394
1395 fs::remove_dir_all(&temp_dir).ok();
1397 }
1398
1399 #[test]
1400 fn test_generate_android_project_replaces_previous_package_tree() {
1401 let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1402 let _ = fs::remove_dir_all(&temp_dir);
1403 fs::create_dir_all(&temp_dir).unwrap();
1404
1405 generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1406 .unwrap();
1407 let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1408 assert!(
1409 old_package_dir.exists(),
1410 "expected first package tree to exist"
1411 );
1412
1413 generate_android_project(
1414 &temp_dir,
1415 "basic_benchmark",
1416 "basic_benchmark::bench_fibonacci",
1417 )
1418 .unwrap();
1419
1420 let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1421 assert!(
1422 new_package_dir.exists(),
1423 "expected new package tree to exist"
1424 );
1425 assert!(
1426 !old_package_dir.exists(),
1427 "old package tree should be removed when regenerating the Android scaffold"
1428 );
1429
1430 fs::remove_dir_all(&temp_dir).ok();
1431 }
1432
1433 #[test]
1434 fn test_is_template_file() {
1435 assert!(is_template_file(Path::new("settings.gradle")));
1436 assert!(is_template_file(Path::new("app/build.gradle")));
1437 assert!(is_template_file(Path::new("AndroidManifest.xml")));
1438 assert!(is_template_file(Path::new("strings.xml")));
1439 assert!(is_template_file(Path::new("MainActivity.kt.template")));
1440 assert!(is_template_file(Path::new("project.yml")));
1441 assert!(is_template_file(Path::new("Info.plist")));
1442 assert!(!is_template_file(Path::new("libfoo.so")));
1443 assert!(!is_template_file(Path::new("image.png")));
1444 }
1445
1446 #[test]
1447 fn test_validate_no_unreplaced_placeholders() {
1448 assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1450
1451 assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1453
1454 let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1456 assert!(result.is_err());
1457 let err = result.unwrap_err().to_string();
1458 assert!(err.contains("{{NAME}}"));
1459 }
1460
1461 #[test]
1462 fn test_to_pascal_case() {
1463 assert_eq!(to_pascal_case("my-project"), "MyProject");
1464 assert_eq!(to_pascal_case("my_project"), "MyProject");
1465 assert_eq!(to_pascal_case("myproject"), "Myproject");
1466 assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1467 }
1468
1469 #[test]
1470 fn test_detect_default_function_finds_benchmark() {
1471 let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1472 let _ = fs::remove_dir_all(&temp_dir);
1473 fs::create_dir_all(temp_dir.join("src")).unwrap();
1474
1475 let lib_content = r#"
1477use mobench_sdk::benchmark;
1478
1479/// Some docs
1480#[benchmark]
1481fn my_benchmark_func() {
1482 // benchmark code
1483}
1484
1485fn helper_func() {}
1486"#;
1487 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1488 fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1489
1490 let result = detect_default_function(&temp_dir, "my_crate");
1491 assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1492
1493 fs::remove_dir_all(&temp_dir).ok();
1495 }
1496
1497 #[test]
1498 fn test_detect_default_function_no_benchmark() {
1499 let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1500 let _ = fs::remove_dir_all(&temp_dir);
1501 fs::create_dir_all(temp_dir.join("src")).unwrap();
1502
1503 let lib_content = r#"
1505fn regular_function() {
1506 // no benchmark here
1507}
1508"#;
1509 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1510
1511 let result = detect_default_function(&temp_dir, "my_crate");
1512 assert!(result.is_none());
1513
1514 fs::remove_dir_all(&temp_dir).ok();
1516 }
1517
1518 #[test]
1519 fn test_detect_default_function_pub_fn() {
1520 let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1521 let _ = fs::remove_dir_all(&temp_dir);
1522 fs::create_dir_all(temp_dir.join("src")).unwrap();
1523
1524 let lib_content = r#"
1526#[benchmark]
1527pub fn public_bench() {
1528 // benchmark code
1529}
1530"#;
1531 fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1532
1533 let result = detect_default_function(&temp_dir, "test-crate");
1534 assert_eq!(result, Some("test_crate::public_bench".to_string()));
1535
1536 fs::remove_dir_all(&temp_dir).ok();
1538 }
1539
1540 #[test]
1541 fn test_resolve_default_function_fallback() {
1542 let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1543 let _ = fs::remove_dir_all(&temp_dir);
1544 fs::create_dir_all(&temp_dir).unwrap();
1545
1546 let result = resolve_default_function(&temp_dir, "my-crate", None);
1548 assert_eq!(result, "my_crate::example_benchmark");
1549
1550 fs::remove_dir_all(&temp_dir).ok();
1552 }
1553
1554 #[test]
1555 fn test_sanitize_bundle_id_component() {
1556 assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1558 assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1560 assert_eq!(
1562 sanitize_bundle_id_component("my-project_name"),
1563 "myprojectname"
1564 );
1565 assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1567 assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1569 assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1571 assert_eq!(
1573 sanitize_bundle_id_component("My-Complex_Project-123"),
1574 "mycomplexproject123"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_generate_ios_project_bundle_id_not_duplicated() {
1580 let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1581 let _ = fs::remove_dir_all(&temp_dir);
1583 fs::create_dir_all(&temp_dir).unwrap();
1584
1585 let crate_name = "bench-mobile";
1587 let bundle_prefix = "dev.world.benchmobile";
1588 let project_pascal = "BenchRunner";
1589
1590 let result = generate_ios_project(
1591 &temp_dir,
1592 crate_name,
1593 project_pascal,
1594 bundle_prefix,
1595 "bench_mobile::test_func",
1596 );
1597 assert!(
1598 result.is_ok(),
1599 "generate_ios_project failed: {:?}",
1600 result.err()
1601 );
1602
1603 let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1605 assert!(project_yml_path.exists(), "project.yml should exist");
1606
1607 let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1609
1610 assert!(
1613 project_yml.contains("dev.world.benchmobile.BenchRunner"),
1614 "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1615 project_yml
1616 );
1617 assert!(
1618 !project_yml.contains("dev.world.benchmobile.benchmobile"),
1619 "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1620 project_yml
1621 );
1622 assert!(
1623 project_yml.contains("embed: false"),
1624 "Static xcframework dependency should be link-only, got:\n{}",
1625 project_yml
1626 );
1627
1628 fs::remove_dir_all(&temp_dir).ok();
1630 }
1631
1632 #[test]
1633 fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1634 let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1635 let _ = fs::remove_dir_all(&temp_dir);
1636 fs::create_dir_all(&temp_dir).unwrap();
1637
1638 generate_ios_project(
1639 &temp_dir,
1640 "bench_mobile",
1641 "BenchRunner",
1642 "dev.world.benchmobile",
1643 "bench_mobile::bench_prepare",
1644 )
1645 .unwrap();
1646
1647 let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1648 fs::create_dir_all(resources_dir.join("nested")).unwrap();
1649 fs::write(
1650 resources_dir.join("bench_spec.json"),
1651 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1652 )
1653 .unwrap();
1654 fs::write(
1655 resources_dir.join("bench_meta.json"),
1656 r#"{"build_id":"build-123"}"#,
1657 )
1658 .unwrap();
1659 fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1660
1661 generate_ios_project(
1662 &temp_dir,
1663 "bench_mobile",
1664 "BenchRunner",
1665 "dev.world.benchmobile",
1666 "bench_mobile::bench_prepare",
1667 )
1668 .unwrap();
1669
1670 assert_eq!(
1671 fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1672 r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1673 );
1674 assert_eq!(
1675 fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1676 r#"{"build_id":"build-123"}"#
1677 );
1678 assert_eq!(
1679 fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1680 "keep me"
1681 );
1682
1683 fs::remove_dir_all(&temp_dir).ok();
1684 }
1685
1686 #[test]
1687 fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1688 let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1689 let _ = fs::remove_dir_all(&temp_dir);
1690 fs::create_dir_all(&temp_dir).unwrap();
1691
1692 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1693 .expect("initial iOS project generation should succeed");
1694
1695 let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1696 assert!(content_view_path.exists(), "ContentView.swift should exist");
1697
1698 fs::write(&content_view_path, "stale generated content").unwrap();
1699
1700 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1701 .expect("refreshing existing iOS project should succeed");
1702
1703 let refreshed = fs::read_to_string(&content_view_path).unwrap();
1704 assert!(
1705 refreshed.contains("ProfileLaunchOptions"),
1706 "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1707 refreshed
1708 );
1709 assert!(
1710 refreshed.contains("repeatUntilMs"),
1711 "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1712 refreshed
1713 );
1714 assert!(
1715 refreshed.contains("Task.detached(priority: .userInitiated)"),
1716 "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
1717 refreshed
1718 );
1719 assert!(
1720 refreshed.contains("await MainActor.run"),
1721 "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
1722 refreshed
1723 );
1724
1725 fs::remove_dir_all(&temp_dir).ok();
1726 }
1727
1728 #[test]
1729 fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
1730 let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
1731 let _ = fs::remove_dir_all(&temp_dir);
1732 fs::create_dir_all(&temp_dir).unwrap();
1733
1734 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1735 .expect("initial iOS project generation should succeed");
1736
1737 let ui_test_path =
1738 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1739 assert!(
1740 ui_test_path.exists(),
1741 "BenchRunnerUITests.swift should exist"
1742 );
1743
1744 fs::write(&ui_test_path, "stale generated content").unwrap();
1745
1746 ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1747 .expect("refreshing existing iOS project should succeed");
1748
1749 let refreshed = fs::read_to_string(&ui_test_path).unwrap();
1750 assert!(
1751 refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
1752 "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
1753 refreshed
1754 );
1755 assert!(
1756 refreshed.contains(
1757 "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
1758 ),
1759 "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
1760 refreshed
1761 );
1762
1763 fs::remove_dir_all(&temp_dir).ok();
1764 }
1765
1766 #[test]
1767 fn test_generate_ios_project_uses_configured_benchmark_timeout() {
1768 let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1769 let _ = fs::remove_dir_all(&temp_dir);
1770 fs::create_dir_all(&temp_dir).unwrap();
1771
1772 let result = generate_ios_project_with_timeout(
1773 &temp_dir,
1774 "sample_fns",
1775 "BenchRunner",
1776 "dev.world.samplefns",
1777 "sample_fns::example_benchmark",
1778 1200,
1779 );
1780
1781 assert!(result.is_ok(), "generate_ios_project should succeed");
1782
1783 let ui_test_path =
1784 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1785 let contents = fs::read_to_string(&ui_test_path).unwrap();
1786 assert!(
1787 contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
1788 "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
1789 contents
1790 );
1791
1792 fs::remove_dir_all(&temp_dir).ok();
1793 }
1794
1795 #[test]
1796 fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
1797 assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
1798 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
1799 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
1800 assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
1801 }
1802
1803 #[test]
1804 fn test_cross_platform_naming_consistency() {
1805 let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1807 let _ = fs::remove_dir_all(&temp_dir);
1808 fs::create_dir_all(&temp_dir).unwrap();
1809
1810 let project_name = "bench-mobile";
1811
1812 let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1814 assert!(
1815 result.is_ok(),
1816 "generate_android_project failed: {:?}",
1817 result.err()
1818 );
1819
1820 let bundle_id_component = sanitize_bundle_id_component(project_name);
1822 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1823 let result = generate_ios_project(
1824 &temp_dir,
1825 &project_name.replace('-', "_"),
1826 "BenchRunner",
1827 &bundle_prefix,
1828 "bench_mobile::test_func",
1829 );
1830 assert!(
1831 result.is_ok(),
1832 "generate_ios_project failed: {:?}",
1833 result.err()
1834 );
1835
1836 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1838 .expect("Failed to read Android build.gradle");
1839
1840 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1842 .expect("Failed to read iOS project.yml");
1843
1844 assert!(
1848 android_build_gradle.contains("dev.world.benchmobile"),
1849 "Android package should be 'dev.world.benchmobile', got:\n{}",
1850 android_build_gradle
1851 );
1852 assert!(
1853 ios_project_yml.contains("dev.world.benchmobile"),
1854 "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1855 ios_project_yml
1856 );
1857
1858 assert!(
1860 !android_build_gradle.contains("dev.world.bench-mobile"),
1861 "Android package should NOT contain hyphens"
1862 );
1863 assert!(
1864 !android_build_gradle.contains("dev.world.bench_mobile"),
1865 "Android package should NOT contain underscores"
1866 );
1867
1868 fs::remove_dir_all(&temp_dir).ok();
1870 }
1871
1872 #[test]
1873 fn test_cross_platform_version_consistency() {
1874 let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1876 let _ = fs::remove_dir_all(&temp_dir);
1877 fs::create_dir_all(&temp_dir).unwrap();
1878
1879 let project_name = "test-project";
1880
1881 let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1883 assert!(
1884 result.is_ok(),
1885 "generate_android_project failed: {:?}",
1886 result.err()
1887 );
1888
1889 let bundle_id_component = sanitize_bundle_id_component(project_name);
1891 let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1892 let result = generate_ios_project(
1893 &temp_dir,
1894 &project_name.replace('-', "_"),
1895 "BenchRunner",
1896 &bundle_prefix,
1897 "test_project::test_func",
1898 );
1899 assert!(
1900 result.is_ok(),
1901 "generate_ios_project failed: {:?}",
1902 result.err()
1903 );
1904
1905 let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1907 .expect("Failed to read Android build.gradle");
1908
1909 let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1911 .expect("Failed to read iOS project.yml");
1912
1913 assert!(
1915 android_build_gradle.contains("versionName \"1.0.0\""),
1916 "Android versionName should be '1.0.0', got:\n{}",
1917 android_build_gradle
1918 );
1919 assert!(
1920 ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1921 "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1922 ios_project_yml
1923 );
1924
1925 fs::remove_dir_all(&temp_dir).ok();
1927 }
1928
1929 #[test]
1930 fn test_bundle_id_prefix_consistency() {
1931 let test_cases = vec![
1933 ("my-project", "dev.world.myproject"),
1934 ("bench_mobile", "dev.world.benchmobile"),
1935 ("TestApp", "dev.world.testapp"),
1936 ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1937 (
1938 "app_with_many_underscores",
1939 "dev.world.appwithmanyunderscores",
1940 ),
1941 ];
1942
1943 for (input, expected_prefix) in test_cases {
1944 let sanitized = sanitize_bundle_id_component(input);
1945 let full_prefix = format!("dev.world.{}", sanitized);
1946 assert_eq!(
1947 full_prefix, expected_prefix,
1948 "For input '{}', expected '{}' but got '{}'",
1949 input, expected_prefix, full_prefix
1950 );
1951 }
1952 }
1953}