use crate::types::{BenchError, InitConfig, Target};
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use include_dir::{Dir, DirEntry, include_dir};
const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android");
const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios");
pub const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
pub const DEFAULT_IOS_DEPLOYMENT_TARGET: &str = "15.0";
pub const SWIFTUI_RUNNER_MIN_IOS: &str = "15.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IosRunner {
Swiftui,
UikitLegacy,
}
impl IosRunner {
pub fn parse(value: &str) -> Result<Self, BenchError> {
match value.trim().to_ascii_lowercase().as_str() {
"swiftui" => Ok(Self::Swiftui),
"uikit-legacy" | "uikit_legacy" => Ok(Self::UikitLegacy),
other => Err(BenchError::Build(format!(
"Unsupported iOS runner `{other}`. Supported values: swiftui, uikit-legacy"
))),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Swiftui => "swiftui",
Self::UikitLegacy => "uikit-legacy",
}
}
}
#[derive(Debug, Clone, Eq)]
pub struct IosDeploymentTarget {
major: u16,
minor: u16,
patch: u16,
raw: String,
}
impl PartialEq for IosDeploymentTarget {
fn eq(&self, other: &Self) -> bool {
(self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
}
}
impl Ord for IosDeploymentTarget {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
}
}
impl PartialOrd for IosDeploymentTarget {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl IosDeploymentTarget {
pub fn parse(value: &str) -> Result<Self, BenchError> {
let raw = value.trim();
if raw.is_empty() {
return Err(BenchError::Build(
"iOS deployment target must not be empty".to_string(),
));
}
let parts = raw.split('.').collect::<Vec<_>>();
if parts.len() > 3 {
return Err(BenchError::Build(format!(
"Invalid iOS deployment target `{raw}`. Expected VERSION like 15.0"
)));
}
let major = parse_ios_version_part(raw, parts[0], "major")?;
let minor = parts
.get(1)
.map(|part| parse_ios_version_part(raw, part, "minor"))
.transpose()?
.unwrap_or(0);
let patch = parts
.get(2)
.map(|part| parse_ios_version_part(raw, part, "patch"))
.transpose()?
.unwrap_or(0);
Ok(Self {
major,
minor,
patch,
raw: raw.to_string(),
})
}
pub fn default_target() -> Self {
Self::parse(DEFAULT_IOS_DEPLOYMENT_TARGET)
.expect("default iOS deployment target should be valid")
}
}
fn parse_ios_version_part(raw: &str, part: &str, label: &str) -> Result<u16, BenchError> {
if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
return Err(BenchError::Build(format!(
"Invalid iOS deployment target `{raw}`: {label} version component must be numeric"
)));
}
part.parse::<u16>().map_err(|err| {
BenchError::Build(format!(
"Invalid iOS deployment target `{raw}`: failed to parse {label} component: {err}"
))
})
}
impl std::fmt::Display for IosDeploymentTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
#[derive(Debug, Clone)]
pub struct IosProjectOptions {
pub deployment_target: IosDeploymentTarget,
pub runner: IosRunner,
pub ios_benchmark_timeout_secs: u64,
}
impl Default for IosProjectOptions {
fn default() -> Self {
Self {
deployment_target: IosDeploymentTarget::default_target(),
runner: IosRunner::Swiftui,
ios_benchmark_timeout_secs: DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS,
}
}
}
pub fn resolve_ios_runner(
deployment_target: &IosDeploymentTarget,
requested_runner: Option<IosRunner>,
) -> Result<IosRunner, BenchError> {
let swiftui_floor = IosDeploymentTarget::parse(SWIFTUI_RUNNER_MIN_IOS)?;
match requested_runner {
Some(IosRunner::Swiftui) if deployment_target < &swiftui_floor => {
Err(BenchError::Build(format!(
"iOS runner `swiftui` requires deployment target {SWIFTUI_RUNNER_MIN_IOS}+; \
requested deployment target is {deployment_target}. Use `uikit-legacy` or raise the deployment target."
)))
}
Some(runner) => Ok(runner),
None if deployment_target < &swiftui_floor => Ok(IosRunner::UikitLegacy),
None => Ok(IosRunner::Swiftui),
}
}
#[derive(Debug, Clone)]
pub struct TemplateVar {
pub name: &'static str,
pub value: String,
}
pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
let output_dir = &config.output_dir;
let project_slug = sanitize_package_name(&config.project_name);
let project_pascal = to_pascal_case(&project_slug);
let bundle_id_component = sanitize_bundle_id_component(&project_slug);
let bundle_prefix = format!("dev.world.{}", bundle_id_component);
fs::create_dir_all(output_dir)?;
generate_bench_mobile_crate(output_dir, &project_slug)?;
let default_function = "example_fibonacci";
match config.target {
Target::Android => {
generate_android_project(output_dir, &project_slug, default_function)?;
}
Target::Ios => {
generate_ios_project(
output_dir,
&project_slug,
&project_pascal,
&bundle_prefix,
default_function,
)?;
}
Target::Both => {
generate_android_project(output_dir, &project_slug, default_function)?;
generate_ios_project(
output_dir,
&project_slug,
&project_pascal,
&bundle_prefix,
default_function,
)?;
}
}
generate_config_file(output_dir, config)?;
if config.generate_examples {
generate_example_benchmarks(output_dir)?;
}
Ok(output_dir.clone())
}
fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
let crate_dir = output_dir.join("bench-mobile");
fs::create_dir_all(crate_dir.join("src"))?;
let crate_name = format!("{}-bench-mobile", project_name);
let cargo_toml = format!(
r#"[package]
name = "{}"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
uniffi = "0.28"
{} = {{ path = ".." }}
[features]
default = []
[build-dependencies]
uniffi = {{ version = "0.28", features = ["build"] }}
# Binary for generating UniFFI bindings (used by mobench build)
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"
# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
#
# Add this to your root Cargo.toml:
# [workspace.dependencies]
# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
#
# Then in each crate that uses rustls:
# [dependencies]
# rustls = {{ workspace = true }}
"#,
crate_name, project_name
);
fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
//!
//! This crate provides the FFI boundary between Rust benchmarks and mobile
//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
use uniffi;
// Ensure the user crate is linked so benchmark registrations are pulled in.
extern crate {{USER_CRATE}} as _bench_user_crate;
// Re-export mobench-sdk types with UniFFI annotations
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
pub struct BenchSpec {
pub name: String,
pub iterations: u32,
pub warmup: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
pub struct BenchSample {
pub duration_ns: u64,
pub cpu_time_ms: Option<u64>,
pub peak_memory_kb: Option<u64>,
pub process_peak_memory_kb: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
pub struct SemanticPhase {
pub name: String,
pub duration_ns: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
pub struct HarnessTimelineSpan {
pub phase: String,
pub start_offset_ns: u64,
pub end_offset_ns: u64,
pub iteration: Option<u32>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
pub struct BenchReport {
pub spec: BenchSpec,
pub samples: Vec<BenchSample>,
pub phases: Vec<SemanticPhase>,
pub timeline: Vec<HarnessTimelineSpan>,
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum BenchError {
#[error("iterations must be greater than zero")]
InvalidIterations,
#[error("unknown benchmark function: {name}")]
UnknownFunction { name: String },
#[error("benchmark execution failed: {reason}")]
ExecutionFailed { reason: String },
}
// Convert from mobench-sdk types
impl From<mobench_sdk::BenchSpec> for BenchSpec {
fn from(spec: mobench_sdk::BenchSpec) -> Self {
Self {
name: spec.name,
iterations: spec.iterations,
warmup: spec.warmup,
}
}
}
impl From<BenchSpec> for mobench_sdk::BenchSpec {
fn from(spec: BenchSpec) -> Self {
Self {
name: spec.name,
iterations: spec.iterations,
warmup: spec.warmup,
}
}
}
impl From<mobench_sdk::BenchSample> for BenchSample {
fn from(sample: mobench_sdk::BenchSample) -> Self {
Self {
duration_ns: sample.duration_ns,
cpu_time_ms: sample.cpu_time_ms,
peak_memory_kb: sample.peak_memory_kb,
process_peak_memory_kb: sample.process_peak_memory_kb,
}
}
}
impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
fn from(phase: mobench_sdk::SemanticPhase) -> Self {
Self {
name: phase.name,
duration_ns: phase.duration_ns,
}
}
}
impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
Self {
phase: span.phase,
start_offset_ns: span.start_offset_ns,
end_offset_ns: span.end_offset_ns,
iteration: span.iteration,
}
}
}
impl From<mobench_sdk::RunnerReport> for BenchReport {
fn from(report: mobench_sdk::RunnerReport) -> Self {
Self {
spec: report.spec.into(),
samples: report.samples.into_iter().map(Into::into).collect(),
phases: report.phases.into_iter().map(Into::into).collect(),
timeline: report.timeline.into_iter().map(Into::into).collect(),
}
}
}
impl From<mobench_sdk::BenchError> for BenchError {
fn from(err: mobench_sdk::BenchError) -> Self {
match err {
mobench_sdk::BenchError::Runner(runner_err) => {
BenchError::ExecutionFailed {
reason: runner_err.to_string(),
}
}
mobench_sdk::BenchError::UnknownFunction(name, _available) => {
BenchError::UnknownFunction { name }
}
_ => BenchError::ExecutionFailed {
reason: err.to_string(),
},
}
}
}
/// Runs a benchmark by name with the given specification
///
/// This is the main FFI entry point called from mobile platforms.
#[uniffi::export]
pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
let sdk_spec: mobench_sdk::BenchSpec = spec.into();
let report = mobench_sdk::run_benchmark(sdk_spec)?;
Ok(report.into())
}
// Generate UniFFI scaffolding
uniffi::setup_scaffolding!();
"#;
let lib_rs = render_template(
lib_rs_template,
&[TemplateVar {
name: "USER_CRATE",
value: project_name.replace('-', "_"),
}],
);
fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
let build_rs = r#"fn main() {
uniffi::generate_scaffolding("src/lib.rs").unwrap();
}
"#;
fs::write(crate_dir.join("build.rs"), build_rs)?;
let bin_dir = crate_dir.join("src/bin");
fs::create_dir_all(&bin_dir)?;
let uniffi_bindgen_rs = r#"fn main() {
uniffi::uniffi_bindgen_main()
}
"#;
fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
Ok(())
}
pub fn generate_android_project(
output_dir: &Path,
project_slug: &str,
default_function: &str,
) -> Result<(), BenchError> {
let target_dir = output_dir.join("android");
reset_generated_project_dir(&target_dir)?;
let library_name = project_slug.replace('-', "_");
let project_pascal = to_pascal_case(project_slug);
let package_id_component = sanitize_bundle_id_component(project_slug);
let package_name = format!("dev.world.{}", package_id_component);
let vars = vec![
TemplateVar {
name: "PROJECT_NAME",
value: project_slug.to_string(),
},
TemplateVar {
name: "PROJECT_NAME_PASCAL",
value: project_pascal.clone(),
},
TemplateVar {
name: "APP_NAME",
value: format!("{} Benchmark", project_pascal),
},
TemplateVar {
name: "PACKAGE_NAME",
value: package_name.clone(),
},
TemplateVar {
name: "UNIFFI_NAMESPACE",
value: library_name.clone(),
},
TemplateVar {
name: "LIBRARY_NAME",
value: library_name,
},
TemplateVar {
name: "DEFAULT_FUNCTION",
value: default_function.to_string(),
},
];
render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
Ok(())
}
fn collect_preserved_files(
root: &Path,
current: &Path,
preserved: &mut Vec<(PathBuf, Vec<u8>)>,
) -> Result<(), BenchError> {
let mut entries = fs::read_dir(current)?
.collect::<Result<Vec<_>, _>>()
.map_err(BenchError::Io)?;
entries.sort_by_key(|entry| entry.path());
for entry in entries {
let path = entry.path();
if path.is_dir() {
collect_preserved_files(root, &path, preserved)?;
continue;
}
let relative = path.strip_prefix(root).map_err(|e| {
BenchError::Build(format!(
"Failed to preserve generated resource {:?}: {}",
path, e
))
})?;
preserved.push((relative.to_path_buf(), fs::read(&path)?));
}
Ok(())
}
fn collect_preserved_ios_resources(
target_dir: &Path,
) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
let mut preserved = Vec::new();
if resources_dir.exists() {
collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
}
Ok(preserved)
}
fn restore_preserved_ios_resources(
target_dir: &Path,
preserved_resources: &[(PathBuf, Vec<u8>)],
) -> Result<(), BenchError> {
if preserved_resources.is_empty() {
return Ok(());
}
let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
for (relative, contents) in preserved_resources {
let resource_path = resources_dir.join(relative);
if let Some(parent) = resource_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(resource_path, contents)?;
}
Ok(())
}
fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
if target_dir.exists() {
fs::remove_dir_all(target_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to clear existing generated project at {:?}: {}",
target_dir, e
))
})?;
}
Ok(())
}
fn move_kotlin_files_to_package_dir(
android_dir: &Path,
package_name: &str,
) -> Result<(), BenchError> {
let package_path = package_name.replace('.', "/");
let main_java_dir = android_dir.join("app/src/main/java");
let main_package_dir = main_java_dir.join(&package_path);
move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
let test_java_dir = android_dir.join("app/src/androidTest/java");
let test_package_dir = test_java_dir.join(&package_path);
move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
Ok(())
}
fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
let src_file = src_dir.join(filename);
if !src_file.exists() {
return Ok(());
}
fs::create_dir_all(dest_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create package directory {:?}: {}",
dest_dir, e
))
})?;
let dest_file = dest_dir.join(filename);
fs::copy(&src_file, &dest_file).map_err(|e| {
BenchError::Build(format!(
"Failed to copy {} to {:?}: {}",
filename, dest_file, e
))
})?;
fs::remove_file(&src_file).map_err(|e| {
BenchError::Build(format!(
"Failed to remove original file {:?}: {}",
src_file, e
))
})?;
Ok(())
}
pub fn generate_ios_project(
output_dir: &Path,
project_slug: &str,
project_pascal: &str,
bundle_prefix: &str,
default_function: &str,
) -> Result<(), BenchError> {
let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
.ok()
.as_deref(),
);
generate_ios_project_with_options(
output_dir,
project_slug,
project_pascal,
bundle_prefix,
default_function,
IosProjectOptions {
ios_benchmark_timeout_secs,
..IosProjectOptions::default()
},
)
}
#[cfg(test)]
#[allow(dead_code)]
fn generate_ios_project_with_timeout(
output_dir: &Path,
project_slug: &str,
project_pascal: &str,
bundle_prefix: &str,
default_function: &str,
ios_benchmark_timeout_secs: u64,
) -> Result<(), BenchError> {
generate_ios_project_with_options(
output_dir,
project_slug,
project_pascal,
bundle_prefix,
default_function,
IosProjectOptions {
ios_benchmark_timeout_secs,
..IosProjectOptions::default()
},
)
}
pub fn generate_ios_project_with_options(
output_dir: &Path,
project_slug: &str,
project_pascal: &str,
bundle_prefix: &str,
default_function: &str,
options: IosProjectOptions,
) -> Result<(), BenchError> {
let runner = resolve_ios_runner(&options.deployment_target, Some(options.runner))?;
let target_dir = output_dir.join("ios");
let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
reset_generated_project_dir(&target_dir)?;
let sanitized_bundle_prefix = {
let parts: Vec<&str> = bundle_prefix.split('.').collect();
parts
.iter()
.map(|part| sanitize_bundle_id_component(part))
.collect::<Vec<_>>()
.join(".")
};
let vars = vec![
TemplateVar {
name: "DEFAULT_FUNCTION",
value: default_function.to_string(),
},
TemplateVar {
name: "PROJECT_NAME_PASCAL",
value: project_pascal.to_string(),
},
TemplateVar {
name: "BUNDLE_ID_PREFIX",
value: sanitized_bundle_prefix.clone(),
},
TemplateVar {
name: "BUNDLE_ID",
value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
},
TemplateVar {
name: "LIBRARY_NAME",
value: project_slug.replace('-', "_"),
},
TemplateVar {
name: "IOS_BENCHMARK_TIMEOUT_SECS",
value: options.ios_benchmark_timeout_secs.to_string(),
},
TemplateVar {
name: "IOS_DEPLOYMENT_TARGET",
value: options.deployment_target.to_string(),
},
TemplateVar {
name: "IOS_RUNNER",
value: runner.as_str().to_string(),
},
];
render_ios_dir(&IOS_TEMPLATES, &target_dir, &vars, runner)?;
restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
Ok(())
}
fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
value
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|secs| *secs > 0)
.unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
}
fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
let config_target = match config.target {
Target::Ios => "ios",
Target::Android | Target::Both => "android",
};
let config_content = format!(
r#"# mobench configuration
# This file controls how benchmarks are executed on devices.
target = "{}"
function = "example_fibonacci"
iterations = 100
warmup = 10
device_matrix = "device-matrix.yaml"
device_tags = ["default"]
[browserstack]
app_automate_username = "${{BROWSERSTACK_USERNAME}}"
app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
project = "{}-benchmarks"
[ios_xcuitest]
app = "target/ios/BenchRunner.ipa"
test_suite = "target/ios/BenchRunnerUITests.zip"
"#,
config_target, config.project_name
);
fs::write(output_dir.join("bench-config.toml"), config_content)?;
Ok(())
}
fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
let examples_dir = output_dir.join("benches");
fs::create_dir_all(&examples_dir)?;
let example_content = r#"//! Example benchmarks
//!
//! This file demonstrates how to write benchmarks with mobench-sdk.
use mobench_sdk::benchmark;
/// Simple benchmark example
#[benchmark]
fn example_fibonacci() {
let result = fibonacci(30);
std::hint::black_box(result);
}
/// Another example with a loop
#[benchmark]
fn example_sum() {
let mut sum = 0u64;
for i in 0..10000 {
sum = sum.wrapping_add(i);
}
std::hint::black_box(sum);
}
// Helper function (not benchmarked)
fn fibonacci(n: u32) -> u64 {
match n {
0 => 0,
1 => 1,
_ => {
let mut a = 0u64;
let mut b = 1u64;
for _ in 2..=n {
let next = a.wrapping_add(b);
a = b;
b = next;
}
b
}
}
}
"#;
fs::write(examples_dir.join("example.rs"), example_content)?;
Ok(())
}
const TEMPLATE_EXTENSIONS: &[&str] = &[
"gradle",
"xml",
"kt",
"java",
"swift",
"yml",
"yaml",
"json",
"toml",
"md",
"txt",
"h",
"m",
"plist",
"pbxproj",
"xcscheme",
"xcworkspacedata",
"entitlements",
"modulemap",
];
fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
render_dir_filtered(dir, out_root, vars, &|_| false)
}
fn render_ios_dir(
dir: &Dir,
out_root: &Path,
vars: &[TemplateVar],
runner: IosRunner,
) -> Result<(), BenchError> {
render_dir_filtered(dir, out_root, vars, &|path| match runner {
IosRunner::Swiftui => {
path == Path::new("BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template")
}
IosRunner::UikitLegacy => {
path == Path::new("BenchRunner/BenchRunner/BenchRunnerApp.swift.template")
|| path == Path::new("BenchRunner/BenchRunner/ContentView.swift.template")
}
})
}
fn render_dir_filtered(
dir: &Dir,
out_root: &Path,
vars: &[TemplateVar],
skip_file: &dyn Fn(&Path) -> bool,
) -> Result<(), BenchError> {
for entry in dir.entries() {
match entry {
DirEntry::Dir(sub) => {
if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
continue;
}
render_dir_filtered(sub, out_root, vars, skip_file)?;
}
DirEntry::File(file) => {
if file.path().components().any(|c| c.as_os_str() == ".gradle") {
continue;
}
if skip_file(file.path()) {
continue;
}
let mut relative = file.path().to_path_buf();
let mut contents = file.contents().to_vec();
let is_explicit_template = relative
.extension()
.map(|ext| ext == "template")
.unwrap_or(false);
let should_render = is_explicit_template || is_template_file(&relative);
if is_explicit_template {
relative.set_extension("");
}
if should_render && let Ok(text) = std::str::from_utf8(&contents) {
let rendered = render_template(text, vars);
validate_no_unreplaced_placeholders(&rendered, &relative)?;
contents = rendered.into_bytes();
}
let out_path = out_root.join(relative);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&out_path, contents)?;
}
}
}
Ok(())
}
fn is_template_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
if ext == "template" {
return true;
}
if let Some(ext_str) = ext.to_str() {
return TEMPLATE_EXTENSIONS.contains(&ext_str);
}
}
if let Some(stem) = path.file_stem() {
let stem_path = Path::new(stem);
if let Some(ext) = stem_path.extension()
&& let Some(ext_str) = ext.to_str()
{
return TEMPLATE_EXTENSIONS.contains(&ext_str);
}
}
false
}
fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
let mut pos = 0;
let mut unreplaced = Vec::new();
while let Some(start) = content[pos..].find("{{") {
let abs_start = pos + start;
if let Some(end) = content[abs_start..].find("}}") {
let placeholder = &content[abs_start..abs_start + end + 2];
let var_name = &content[abs_start + 2..abs_start + end];
if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
unreplaced.push(placeholder.to_string());
}
pos = abs_start + end + 2;
} else {
break;
}
}
if !unreplaced.is_empty() {
return Err(BenchError::Build(format!(
"Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
This is a bug in mobench-sdk. Please report it at:\n\
https://github.com/worldcoin/mobile-bench-rs/issues",
file_path, unreplaced
)));
}
Ok(())
}
fn render_template(input: &str, vars: &[TemplateVar]) -> String {
let mut output = input.to_string();
for var in vars {
output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
}
output
}
pub fn sanitize_bundle_id_component(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_lowercase()
}
fn sanitize_package_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.replace("--", "-")
}
pub fn to_pascal_case(input: &str) -> String {
input
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
let first = chars.next().unwrap().to_ascii_uppercase();
let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
format!("{}{}", first, rest)
})
.collect::<String>()
}
pub fn android_project_exists(output_dir: &Path) -> bool {
let android_dir = output_dir.join("android");
android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
}
pub fn ios_project_exists(output_dir: &Path) -> bool {
output_dir.join("ios/BenchRunner/project.yml").exists()
}
fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
let project_yml = output_dir.join("ios/BenchRunner/project.yml");
let Ok(content) = std::fs::read_to_string(&project_yml) else {
return false;
};
let expected = format!("../{}.xcframework", library_name);
content.contains(&expected)
}
fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
let build_gradle = output_dir.join("android/app/build.gradle");
let Ok(content) = std::fs::read_to_string(&build_gradle) else {
return false;
};
let expected = format!("lib{}.so", library_name);
content.contains(&expected)
}
pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
let lib_rs = crate_dir.join("src/lib.rs");
if !lib_rs.exists() {
return None;
}
let file = fs::File::open(&lib_rs).ok()?;
let reader = BufReader::new(file);
let mut found_benchmark_attr = false;
let crate_name_normalized = crate_name.replace('-', "_");
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
found_benchmark_attr = true;
continue;
}
if found_benchmark_attr {
if let Some(fn_pos) = trimmed.find("fn ") {
let after_fn = &trimmed[fn_pos + 3..];
let fn_name: String = after_fn
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !fn_name.is_empty() {
return Some(format!("{}::{}", crate_name_normalized, fn_name));
}
}
if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
found_benchmark_attr = false;
}
}
}
None
}
pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
let lib_rs = crate_dir.join("src/lib.rs");
if !lib_rs.exists() {
return Vec::new();
}
let Ok(file) = fs::File::open(&lib_rs) else {
return Vec::new();
};
let reader = BufReader::new(file);
let mut benchmarks = Vec::new();
let mut found_benchmark_attr = false;
let crate_name_normalized = crate_name.replace('-', "_");
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
found_benchmark_attr = true;
continue;
}
if found_benchmark_attr {
if let Some(fn_pos) = trimmed.find("fn ") {
let after_fn = &trimmed[fn_pos + 3..];
let fn_name: String = after_fn
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !fn_name.is_empty() {
benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
}
found_benchmark_attr = false;
}
if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
found_benchmark_attr = false;
}
}
}
benchmarks
}
pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
let crate_name_normalized = crate_name.replace('-', "_");
let normalized_name = if function_name.contains("::") {
function_name.to_string()
} else {
format!("{}::{}", crate_name_normalized, function_name)
};
benchmarks.iter().any(|b| b == &normalized_name)
}
pub fn resolve_default_function(
project_root: &Path,
crate_name: &str,
crate_dir: Option<&Path>,
) -> String {
let crate_name_normalized = crate_name.replace('-', "_");
let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
vec![dir.to_path_buf()]
} else {
vec![
project_root.join("bench-mobile"),
project_root.join("crates").join(crate_name),
project_root.to_path_buf(),
]
};
for dir in &search_dirs {
if dir.join("Cargo.toml").exists()
&& let Some(detected) = detect_default_function(dir, &crate_name_normalized)
{
return detected;
}
}
format!("{}::example_benchmark", crate_name_normalized)
}
pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
ensure_android_project_with_options(output_dir, crate_name, None, None)
}
pub fn ensure_android_project_with_options(
output_dir: &Path,
crate_name: &str,
project_root: Option<&Path>,
crate_dir: Option<&Path>,
) -> Result<(), BenchError> {
let library_name = crate_name.replace('-', "_");
if android_project_exists(output_dir)
&& android_project_matches_library(output_dir, &library_name)
{
return Ok(());
}
println!("Android project not found, generating scaffolding...");
let project_slug = crate_name.replace('-', "_");
let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
generate_android_project(output_dir, &project_slug, &default_function)?;
println!(
" Generated Android project at {:?}",
output_dir.join("android")
);
println!(" Default benchmark function: {}", default_function);
Ok(())
}
pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
ensure_ios_project_with_options(output_dir, crate_name, None, None)
}
pub fn ensure_ios_project_with_options(
output_dir: &Path,
crate_name: &str,
project_root: Option<&Path>,
crate_dir: Option<&Path>,
) -> Result<(), BenchError> {
ensure_ios_project_with_project_options(
output_dir,
crate_name,
project_root,
crate_dir,
IosProjectOptions::default(),
)
}
pub fn ensure_ios_project_with_project_options(
output_dir: &Path,
crate_name: &str,
project_root: Option<&Path>,
crate_dir: Option<&Path>,
options: IosProjectOptions,
) -> Result<(), BenchError> {
let library_name = crate_name.replace('-', "_");
let project_exists = ios_project_exists(output_dir);
let project_matches = ios_project_matches_library(output_dir, &library_name);
if project_exists && !project_matches {
println!("Existing iOS scaffolding does not match library, regenerating...");
} else if project_exists {
println!("Refreshing generated iOS scaffolding...");
} else {
println!("iOS project not found, generating scaffolding...");
}
let project_pascal = "BenchRunner";
let library_name = crate_name.replace('-', "_");
let bundle_id_component = sanitize_bundle_id_component(crate_name);
let bundle_prefix = format!("dev.world.{}", bundle_id_component);
let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
generate_ios_project_with_options(
output_dir,
&library_name,
project_pascal,
&bundle_prefix,
&default_function,
options,
)?;
println!(" Generated iOS project at {:?}", output_dir.join("ios"));
println!(" Default benchmark function: {}", default_function);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_generate_bench_mobile_crate() {
let temp_dir = env::temp_dir().join("mobench-sdk-test");
fs::create_dir_all(&temp_dir).unwrap();
let result = generate_bench_mobile_crate(&temp_dir, "test_project");
assert!(result.is_ok());
assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
assert!(temp_dir.join("bench-mobile/build.rs").exists());
let cargo_toml =
fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
assert!(
cargo_toml.contains(
r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
),
"generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_generate_android_project_no_unreplaced_placeholders() {
let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let result =
generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
assert!(
result.is_ok(),
"generate_android_project failed: {:?}",
result.err()
);
let android_dir = temp_dir.join("android");
assert!(android_dir.join("settings.gradle").exists());
assert!(android_dir.join("app/build.gradle").exists());
assert!(
android_dir
.join("app/src/main/AndroidManifest.xml")
.exists()
);
assert!(
android_dir
.join("app/src/main/res/values/strings.xml")
.exists()
);
assert!(
android_dir
.join("app/src/main/res/values/themes.xml")
.exists()
);
let files_to_check = [
"settings.gradle",
"app/build.gradle",
"app/src/main/AndroidManifest.xml",
"app/src/main/res/values/strings.xml",
"app/src/main/res/values/themes.xml",
];
for file in files_to_check {
let path = android_dir.join(file);
let contents =
fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
let has_placeholder = contents.contains("{{") && contents.contains("}}");
assert!(
!has_placeholder,
"File {} contains unreplaced template placeholders: {}",
file, contents
);
}
let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
assert!(
settings.contains("my-bench-project-android")
|| settings.contains("my_bench_project-android"),
"settings.gradle should contain project name"
);
let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
assert!(
build_gradle.contains("dev.world.mybenchproject"),
"build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
);
assert!(
!build_gradle.contains("testBuildType \"release\""),
"debug builds should be able to produce assembleDebugAndroidTest"
);
assert!(
build_gradle.contains("mobenchTestBuildType"),
"release builds should be able to request assembleReleaseAndroidTest"
);
let manifest =
fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
assert!(
manifest.contains("Theme.MyBenchProject"),
"AndroidManifest.xml should contain PascalCase theme name"
);
let strings =
fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
assert!(
strings.contains("Benchmark"),
"strings.xml should contain app name with Benchmark"
);
let main_activity_path =
android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
assert!(
main_activity_path.exists(),
"MainActivity.kt should be in package directory: {:?}",
main_activity_path
);
let test_activity_path = android_dir
.join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
assert!(
test_activity_path.exists(),
"MainActivityTest.kt should be in package directory: {:?}",
test_activity_path
);
assert!(
!android_dir
.join("app/src/main/java/MainActivity.kt")
.exists(),
"MainActivity.kt should not be in root java directory"
);
assert!(
!android_dir
.join("app/src/androidTest/java/MainActivityTest.kt")
.exists(),
"MainActivityTest.kt should not be in root java directory"
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_generate_android_project_replaces_previous_package_tree() {
let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
.unwrap();
let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
assert!(
old_package_dir.exists(),
"expected first package tree to exist"
);
generate_android_project(
&temp_dir,
"basic_benchmark",
"basic_benchmark::bench_fibonacci",
)
.unwrap();
let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
assert!(
new_package_dir.exists(),
"expected new package tree to exist"
);
assert!(
!old_package_dir.exists(),
"old package tree should be removed when regenerating the Android scaffold"
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_is_template_file() {
assert!(is_template_file(Path::new("settings.gradle")));
assert!(is_template_file(Path::new("app/build.gradle")));
assert!(is_template_file(Path::new("AndroidManifest.xml")));
assert!(is_template_file(Path::new("strings.xml")));
assert!(is_template_file(Path::new("MainActivity.kt.template")));
assert!(is_template_file(Path::new("project.yml")));
assert!(is_template_file(Path::new("Info.plist")));
assert!(!is_template_file(Path::new("libfoo.so")));
assert!(!is_template_file(Path::new("image.png")));
}
#[test]
fn test_mobile_templates_read_process_peak_memory_compatibly() {
let android =
include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
assert!(
!android.contains("sample.processPeakMemoryKb"),
"Android template should not require generated bindings to expose processPeakMemoryKb"
);
assert!(
!android.contains("it.processPeakMemoryKb"),
"Android template should not require generated bindings to expose processPeakMemoryKb"
);
assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
assert!(
!android.contains("sample.cpuTimeMs"),
"Android template should tolerate BenchSample without cpuTimeMs"
);
assert!(
!android.contains("sample.peakMemoryKb"),
"Android template should tolerate BenchSample without peakMemoryKb"
);
assert!(
!android.contains("report.phases"),
"Android template should tolerate BenchReport without phases"
);
assert!(android.contains("ProcessMemorySampler"));
assert!(android.contains("sampleIntervalMs: Long = 1000L"));
assert!(android.contains("/proc/self/smaps_rollup"));
assert!(android.contains("class BenchmarkWorkerService : Service()"));
assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
assert!(android.contains("startForegroundService(intent)"));
assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
assert!(android.contains("fun isBenchmarkComplete()"));
assert!(android.contains("BENCH_JSON ${json}"));
assert!(android.contains("BENCH_HEARTBEAT_JSON $json"));
assert!(android.contains("BENCH_FAILURE_JSON $encoded"));
assert!(android.contains("getHistoricalProcessExitReasons"));
assert!(android.contains("ApplicationExitInfo.REASON_LOW_MEMORY"));
assert!(android.contains("android_benchmark_timeout_secs"));
assert!(android.contains("android_heartbeat_interval_secs"));
assert!(!android.contains("resultLatch.await"));
assert!(android.contains("memory_process\", \"isolated_worker\""));
let android_test = include_str!(
"../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
);
assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
assert!(android_test.contains("Thread.sleep(pollMs)"));
assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
assert!(android_test.contains("activity.isBenchmarkComplete()"));
assert!(android_test.contains("activity.isBenchmarkFailed()"));
assert!(android_test.contains("activity.emitTimeoutFailureFromTest()"));
assert!(android_test.contains("activity.checkWorkerExit()"));
assert!(android_test.contains("Benchmark failed before BENCH_JSON"));
let ios_test = include_str!(
"../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
);
assert!(
ios_test.contains("\\\"error\\\""),
"iOS XCUITest template should fail when the benchmark report is an error payload"
);
let android_manifest =
include_str!("../templates/android/app/src/main/AndroidManifest.xml");
assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
assert!(android_manifest.contains("android:process=\":mobench_worker\""));
let android_build_gradle = include_str!("../templates/android/app/build.gradle");
assert!(android_build_gradle.contains("generatedMainBenchSpec"));
assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
let ios =
include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
assert!(
!ios.contains("sample.processPeakMemoryKb"),
"iOS template should not require generated bindings to expose processPeakMemoryKb"
);
assert!(
!ios.contains(r"\.processPeakMemoryKb"),
"iOS template should not require generated bindings to expose processPeakMemoryKb"
);
assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
assert!(ios.contains("return [\n \"name\": name,"));
assert!(
!ios.contains("sample.cpuTimeMs"),
"iOS template should tolerate BenchSample without cpuTimeMs"
);
assert!(
!ios.contains("sample.peakMemoryKb"),
"iOS template should tolerate BenchSample without peakMemoryKb"
);
assert!(
!ios.contains("report.phases"),
"iOS template should tolerate BenchReport without phases"
);
assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
assert!(ios.contains("ProcessMemorySampler"));
assert!(ios.contains("currentProcessResidentMemoryKb"));
assert!(ios.contains("task_info("));
assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
let legacy = include_str!(
"../templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template"
);
assert!(legacy.contains("import UIKit"));
assert!(!legacy.contains("import SwiftUI"));
assert!(!legacy.contains("Task.detached"));
assert!(!legacy.contains("Task.sleep"));
assert!(!legacy.contains("MainActor"));
assert!(legacy.contains("DispatchQueue.global(qos: .userInitiated)"));
assert!(legacy.contains("DispatchQueue.main.async"));
assert!(legacy.contains("textColor = .clear"));
assert!(!legacy.contains(".alpha = 0"));
assert!(legacy.contains("benchmarkReport"));
assert!(legacy.contains("benchmarkCompleted"));
assert!(legacy.contains("benchmarkReportJSON"));
assert!(legacy.contains("BENCH_REPORT_JSON_START"));
assert!(legacy.contains("BENCH_REPORT_JSON_END"));
}
#[test]
fn test_ios_deployment_target_and_runner_selection() {
let ios15 = IosDeploymentTarget::parse("15.0").unwrap();
let ios10 = IosDeploymentTarget::parse("10.0").unwrap();
assert_eq!(IosDeploymentTarget::parse("10").unwrap(), ios10);
assert_eq!(
resolve_ios_runner(&ios15, None).unwrap(),
IosRunner::Swiftui
);
assert_eq!(
resolve_ios_runner(&ios10, None).unwrap(),
IosRunner::UikitLegacy
);
assert!(resolve_ios_runner(&ios10, Some(IosRunner::Swiftui)).is_err());
assert_eq!(
resolve_ios_runner(&ios15, Some(IosRunner::UikitLegacy)).unwrap(),
IosRunner::UikitLegacy
);
}
#[test]
fn test_validate_no_unreplaced_placeholders() {
assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("{{NAME}}"));
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("my-project"), "MyProject");
assert_eq!(to_pascal_case("my_project"), "MyProject");
assert_eq!(to_pascal_case("myproject"), "Myproject");
assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
}
#[test]
fn test_detect_default_function_finds_benchmark() {
let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(temp_dir.join("src")).unwrap();
let lib_content = r#"
use mobench_sdk::benchmark;
/// Some docs
#[benchmark]
fn my_benchmark_func() {
// benchmark code
}
fn helper_func() {}
"#;
fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
let result = detect_default_function(&temp_dir, "my_crate");
assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_detect_default_function_no_benchmark() {
let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(temp_dir.join("src")).unwrap();
let lib_content = r#"
fn regular_function() {
// no benchmark here
}
"#;
fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
let result = detect_default_function(&temp_dir, "my_crate");
assert!(result.is_none());
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_detect_default_function_pub_fn() {
let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(temp_dir.join("src")).unwrap();
let lib_content = r#"
#[benchmark]
pub fn public_bench() {
// benchmark code
}
"#;
fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
let result = detect_default_function(&temp_dir, "test-crate");
assert_eq!(result, Some("test_crate::public_bench".to_string()));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_resolve_default_function_fallback() {
let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let result = resolve_default_function(&temp_dir, "my-crate", None);
assert_eq!(result, "my_crate::example_benchmark");
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_sanitize_bundle_id_component() {
assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
assert_eq!(
sanitize_bundle_id_component("my-project_name"),
"myprojectname"
);
assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
assert_eq!(
sanitize_bundle_id_component("My-Complex_Project-123"),
"mycomplexproject123"
);
}
#[test]
fn test_generate_ios_project_bundle_id_not_duplicated() {
let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let crate_name = "bench-mobile";
let bundle_prefix = "dev.world.benchmobile";
let project_pascal = "BenchRunner";
let result = generate_ios_project(
&temp_dir,
crate_name,
project_pascal,
bundle_prefix,
"bench_mobile::test_func",
);
assert!(
result.is_ok(),
"generate_ios_project failed: {:?}",
result.err()
);
let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
assert!(project_yml_path.exists(), "project.yml should exist");
let project_yml = fs::read_to_string(&project_yml_path).unwrap();
assert!(
project_yml.contains("dev.world.benchmobile.BenchRunner"),
"Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
project_yml
);
assert!(
!project_yml.contains("dev.world.benchmobile.benchmobile"),
"Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
project_yml
);
assert!(
project_yml.contains("embed: false"),
"Static xcframework dependency should be link-only, got:\n{}",
project_yml
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
generate_ios_project(
&temp_dir,
"bench_mobile",
"BenchRunner",
"dev.world.benchmobile",
"bench_mobile::bench_prepare",
)
.unwrap();
let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
fs::create_dir_all(resources_dir.join("nested")).unwrap();
fs::write(
resources_dir.join("bench_spec.json"),
r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
)
.unwrap();
fs::write(
resources_dir.join("bench_meta.json"),
r#"{"build_id":"build-123"}"#,
)
.unwrap();
fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
generate_ios_project(
&temp_dir,
"bench_mobile",
"BenchRunner",
"dev.world.benchmobile",
"bench_mobile::bench_prepare",
)
.unwrap();
assert_eq!(
fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
);
assert_eq!(
fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
r#"{"build_id":"build-123"}"#
);
assert_eq!(
fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
"keep me"
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_ensure_ios_project_refreshes_existing_content_view_template() {
let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
.expect("initial iOS project generation should succeed");
let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
assert!(content_view_path.exists(), "ContentView.swift should exist");
fs::write(&content_view_path, "stale generated content").unwrap();
ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
.expect("refreshing existing iOS project should succeed");
let refreshed = fs::read_to_string(&content_view_path).unwrap();
assert!(
refreshed.contains("ProfileLaunchOptions"),
"refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
refreshed
);
assert!(
refreshed.contains("repeatUntilMs"),
"refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
refreshed
);
assert!(
refreshed.contains("Task.detached(priority: .userInitiated)"),
"refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
refreshed
);
assert!(
refreshed.contains("await MainActor.run"),
"refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
refreshed
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
.expect("initial iOS project generation should succeed");
let ui_test_path =
temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
assert!(
ui_test_path.exists(),
"BenchRunnerUITests.swift should exist"
);
fs::write(&ui_test_path, "stale generated content").unwrap();
ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
.expect("refreshing existing iOS project should succeed");
let refreshed = fs::read_to_string(&ui_test_path).unwrap();
assert!(
refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
"refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
refreshed
);
assert!(
refreshed.contains(
"ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
),
"refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
refreshed
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_generate_ios_project_uses_configured_benchmark_timeout() {
let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let result = generate_ios_project_with_timeout(
&temp_dir,
"sample_fns",
"BenchRunner",
"dev.world.samplefns",
"sample_fns::example_benchmark",
1200,
);
assert!(result.is_ok(), "generate_ios_project should succeed");
let ui_test_path =
temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
let contents = fs::read_to_string(&ui_test_path).unwrap();
assert!(
contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
"generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
contents
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_generate_ios_project_uses_configured_deployment_target() {
let temp_dir = env::temp_dir().join("mobench-sdk-ios-deployment-target-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
generate_ios_project_with_options(
&temp_dir,
"sample_fns",
"BenchRunner",
"dev.world.samplefns",
"sample_fns::example_benchmark",
IosProjectOptions {
deployment_target: IosDeploymentTarget::parse("10.0").unwrap(),
runner: IosRunner::UikitLegacy,
ios_benchmark_timeout_secs: 300,
},
)
.expect("generate legacy iOS project");
let project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")).unwrap();
assert!(project_yml.contains("deploymentTarget: \"10.0\""));
assert!(!project_yml.contains("deploymentTarget: \"15.0\""));
let runner = fs::read_to_string(
temp_dir.join("ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift"),
)
.unwrap();
assert!(runner.contains("import UIKit"));
assert!(
!temp_dir
.join("ios/BenchRunner/BenchRunner/ContentView.swift")
.exists()
);
assert!(
!temp_dir
.join("ios/BenchRunner/BenchRunner/BenchRunnerApp.swift")
.exists()
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
}
#[test]
fn test_cross_platform_naming_consistency() {
let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let project_name = "bench-mobile";
let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
assert!(
result.is_ok(),
"generate_android_project failed: {:?}",
result.err()
);
let bundle_id_component = sanitize_bundle_id_component(project_name);
let bundle_prefix = format!("dev.world.{}", bundle_id_component);
let result = generate_ios_project(
&temp_dir,
&project_name.replace('-', "_"),
"BenchRunner",
&bundle_prefix,
"bench_mobile::test_func",
);
assert!(
result.is_ok(),
"generate_ios_project failed: {:?}",
result.err()
);
let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
.expect("Failed to read Android build.gradle");
let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
.expect("Failed to read iOS project.yml");
assert!(
android_build_gradle.contains("dev.world.benchmobile"),
"Android package should be 'dev.world.benchmobile', got:\n{}",
android_build_gradle
);
assert!(
ios_project_yml.contains("dev.world.benchmobile"),
"iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
ios_project_yml
);
assert!(
!android_build_gradle.contains("dev.world.bench-mobile"),
"Android package should NOT contain hyphens"
);
assert!(
!android_build_gradle.contains("dev.world.bench_mobile"),
"Android package should NOT contain underscores"
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_cross_platform_version_consistency() {
let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let project_name = "test-project";
let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
assert!(
result.is_ok(),
"generate_android_project failed: {:?}",
result.err()
);
let bundle_id_component = sanitize_bundle_id_component(project_name);
let bundle_prefix = format!("dev.world.{}", bundle_id_component);
let result = generate_ios_project(
&temp_dir,
&project_name.replace('-', "_"),
"BenchRunner",
&bundle_prefix,
"test_project::test_func",
);
assert!(
result.is_ok(),
"generate_ios_project failed: {:?}",
result.err()
);
let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
.expect("Failed to read Android build.gradle");
let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
.expect("Failed to read iOS project.yml");
assert!(
android_build_gradle.contains("versionName \"1.0.0\""),
"Android versionName should be '1.0.0', got:\n{}",
android_build_gradle
);
assert!(
ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
"iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
ios_project_yml
);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_bundle_id_prefix_consistency() {
let test_cases = vec![
("my-project", "dev.world.myproject"),
("bench_mobile", "dev.world.benchmobile"),
("TestApp", "dev.world.testapp"),
("app-with-many-dashes", "dev.world.appwithmanydashes"),
(
"app_with_many_underscores",
"dev.world.appwithmanyunderscores",
),
];
for (input, expected_prefix) in test_cases {
let sanitized = sanitize_bundle_id_component(input);
let full_prefix = format!("dev.world.{}", sanitized);
assert_eq!(
full_prefix, expected_prefix,
"For input '{}', expected '{}' but got '{}'",
input, expected_prefix, full_prefix
);
}
}
}