use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub const CONFIG_FILE_NAME: &str = "mobench.toml";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MobenchConfig {
pub project: ProjectConfig,
pub android: AndroidConfig,
pub ios: IosConfig,
pub benchmarks: BenchmarksConfig,
pub browserstack: BrowserStackConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
#[serde(rename = "crate")]
pub crate_name: Option<String>,
pub library_name: Option<String>,
pub output_dir: Option<PathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct RawMobenchConfig {
project: RawProjectConfig,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct RawProjectConfig {
ffi_backend: Option<mobench_sdk::FfiBackend>,
}
pub(crate) fn load_ffi_backend_from_file(path: &Path) -> Result<mobench_sdk::FfiBackend> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let config: RawMobenchConfig = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(config.project.ffi_backend.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AndroidConfig {
pub package: String,
pub min_sdk: u32,
pub target_sdk: u32,
pub abis: Option<Vec<String>>,
}
impl Default for AndroidConfig {
fn default() -> Self {
Self {
package: "dev.world.bench".to_string(),
min_sdk: 24,
target_sdk: 34,
abis: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IosConfig {
pub bundle_id: String,
pub deployment_target: String,
pub team_id: Option<String>,
}
impl Default for IosConfig {
fn default() -> Self {
Self {
bundle_id: "dev.world.bench".to_string(),
deployment_target: "15.0".to_string(),
team_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BenchmarksConfig {
pub default_function: Option<String>,
pub default_iterations: u32,
pub default_warmup: u32,
}
impl Default for BenchmarksConfig {
fn default() -> Self {
Self {
default_function: None,
default_iterations: 100,
default_warmup: 10,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BrowserStackConfig {
pub ios_completion_timeout_secs: Option<u64>,
}
impl MobenchConfig {
pub fn new() -> Self {
Self::default()
}
pub fn load_from_file(path: &Path) -> Result<Self> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
load_ffi_backend_from_file(path)?;
let config: MobenchConfig = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(config)
}
pub fn discover() -> Result<Option<(Self, PathBuf)>> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
Self::discover_from(&cwd)
}
pub fn discover_from(start_dir: &Path) -> Result<Option<(Self, PathBuf)>> {
let mut current = start_dir.to_path_buf();
loop {
let config_path = current.join(CONFIG_FILE_NAME);
if config_path.is_file() {
let config = Self::load_from_file(&config_path)?;
return Ok(Some((config, config_path)));
}
if current.join(".git").exists() || !current.pop() {
break;
}
}
Ok(None)
}
pub fn save_to_file(&self, path: &Path) -> Result<()> {
let contents = toml::to_string_pretty(self).context("Failed to serialize configuration")?;
fs::write(path, contents)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
Ok(())
}
pub fn library_name(&self) -> Option<String> {
self.project.library_name.clone().or_else(|| {
self.project
.crate_name
.as_ref()
.map(|c| c.replace('-', "_"))
})
}
pub fn starter(crate_name: &str) -> Self {
let library_name = crate_name.replace('-', "_");
let package = format!("dev.world.{}", library_name.replace('_', ""));
Self {
project: ProjectConfig {
crate_name: Some(crate_name.to_string()),
library_name: Some(library_name.clone()),
output_dir: None, },
android: AndroidConfig {
package: package.clone(),
min_sdk: 24,
target_sdk: 34,
abis: None,
},
ios: IosConfig {
bundle_id: package,
deployment_target: "15.0".to_string(),
team_id: None,
},
benchmarks: BenchmarksConfig {
default_function: Some(format!("{}::my_benchmark", library_name)),
default_iterations: 100,
default_warmup: 10,
},
browserstack: BrowserStackConfig::default(),
}
}
pub fn generate_starter_toml(crate_name: &str) -> String {
let library_name = crate_name.replace('-', "_");
let package = format!("dev.world.{}", library_name.replace('_', ""));
format!(
r#"# mobench configuration file
# This file configures mobench for building and running mobile benchmarks.
# CLI flags override these settings when provided.
[project]
# Name of the benchmark crate
crate = "{crate_name}"
# Rust library name (typically crate name with hyphens replaced by underscores)
library_name = "{library_name}"
# Output directory for build artifacts (default: target/mobench/)
# output_dir = "target/mobench"
# FFI backend for generated mobile runners: "uniffi" (default) or "native-c-abi".
# Use "native-c-abi" for ProveKit-style benchmarks that need to avoid UniFFI overhead.
# ffi_backend = "uniffi"
[android]
# Android package name
package = "{package}"
# Minimum Android SDK version (default: 24 / Android 7.0)
min_sdk = 24
# Target Android SDK version (default: 34 / Android 14)
target_sdk = 34
# Android ABIs to build for (optional, defaults to arm64-v8a)
# abis = ["arm64-v8a"]
[ios]
# iOS bundle identifier
bundle_id = "{package}"
# iOS deployment target version (default: 15.0)
deployment_target = "15.0"
# Development team ID for code signing (optional, uses ad-hoc signing if not set)
# team_id = "YOUR_TEAM_ID"
[benchmarks]
# Default benchmark function to run
default_function = "{library_name}::my_benchmark"
# Default number of benchmark iterations (can be overridden with --iterations)
default_iterations = 100
# Default number of warmup iterations (can be overridden with --warmup)
default_warmup = 10
[browserstack]
# Timeout in seconds for the generated iOS XCUITest harness to wait for completion
# ios_completion_timeout_secs = 1200
"#,
crate_name = crate_name,
library_name = library_name,
package = package,
)
}
}
#[derive(Debug, Default)]
pub struct ConfigResolver {
pub config: Option<MobenchConfig>,
pub config_path: Option<PathBuf>,
}
impl ConfigResolver {
pub fn new() -> Result<Self> {
match MobenchConfig::discover()? {
Some((config, path)) => Ok(Self {
config: Some(config),
config_path: Some(path),
}),
None => Ok(Self {
config: None,
config_path: None,
}),
}
}
pub fn crate_name(&self) -> Option<&str> {
self.config
.as_ref()
.and_then(|c| c.project.crate_name.as_deref())
}
pub fn library_name(&self) -> Option<String> {
self.config.as_ref().and_then(|c| c.library_name())
}
pub fn output_dir(&self) -> Option<&Path> {
self.config
.as_ref()
.and_then(|c| c.project.output_dir.as_deref())
}
pub fn ffi_backend(&self) -> mobench_sdk::FfiBackend {
self.config_path
.as_deref()
.and_then(|path| load_ffi_backend_from_file(path).ok())
.unwrap_or_default()
}
pub fn default_function(&self) -> Option<&str> {
self.config
.as_ref()
.and_then(|c| c.benchmarks.default_function.as_deref())
}
pub fn default_iterations(&self) -> u32 {
self.config
.as_ref()
.map(|c| c.benchmarks.default_iterations)
.unwrap_or(100)
}
pub fn default_warmup(&self) -> u32 {
self.config
.as_ref()
.map(|c| c.benchmarks.default_warmup)
.unwrap_or(10)
}
pub fn android(&self) -> AndroidConfig {
self.config
.as_ref()
.map(|c| c.android.clone())
.unwrap_or_default()
}
pub fn ios(&self) -> IosConfig {
self.config
.as_ref()
.map(|c| c.ios.clone())
.unwrap_or_default()
}
pub fn resolve<T, F>(&self, cli_value: Option<T>, config_getter: F, default: T) -> T
where
F: FnOnce(&MobenchConfig) -> Option<T>,
{
cli_value
.or_else(|| self.config.as_ref().and_then(config_getter))
.unwrap_or(default)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_default_config() {
let config = MobenchConfig::default();
assert_eq!(config.android.min_sdk, 24);
assert_eq!(config.android.target_sdk, 34);
assert_eq!(config.ios.deployment_target, "15.0");
assert_eq!(config.benchmarks.default_iterations, 100);
assert_eq!(config.benchmarks.default_warmup, 10);
assert_eq!(config.browserstack.ios_completion_timeout_secs, None);
}
#[test]
fn test_starter_config() {
let config = MobenchConfig::starter("my-bench");
assert_eq!(config.project.crate_name, Some("my-bench".to_string()));
assert_eq!(config.project.library_name, Some("my_bench".to_string()));
assert_eq!(config.android.package, "dev.world.mybench");
assert_eq!(config.ios.bundle_id, "dev.world.mybench");
assert_eq!(config.browserstack.ios_completion_timeout_secs, None);
}
#[test]
fn test_load_from_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("mobench.toml");
let toml_content = r#"
[project]
crate = "test-bench"
library_name = "test_bench"
ffi_backend = "native-c-abi"
[android]
package = "com.test.bench"
min_sdk = 21
target_sdk = 33
[ios]
bundle_id = "com.test.bench"
deployment_target = "14.0"
[benchmarks]
default_function = "test_bench::test_fn"
default_iterations = 50
default_warmup = 5
[browserstack]
ios_completion_timeout_secs = 1200
"#;
let mut file = std::fs::File::create(&config_path).unwrap();
file.write_all(toml_content.as_bytes()).unwrap();
let config = MobenchConfig::load_from_file(&config_path).unwrap();
assert_eq!(config.project.crate_name, Some("test-bench".to_string()));
assert_eq!(config.project.library_name, Some("test_bench".to_string()));
assert_eq!(
load_ffi_backend_from_file(&config_path).unwrap(),
mobench_sdk::FfiBackend::NativeCAbi
);
assert_eq!(config.android.package, "com.test.bench");
assert_eq!(config.android.min_sdk, 21);
assert_eq!(config.android.target_sdk, 33);
assert_eq!(config.ios.bundle_id, "com.test.bench");
assert_eq!(config.ios.deployment_target, "14.0");
assert_eq!(
config.benchmarks.default_function,
Some("test_bench::test_fn".to_string())
);
assert_eq!(config.benchmarks.default_iterations, 50);
assert_eq!(config.benchmarks.default_warmup, 5);
assert_eq!(config.browserstack.ios_completion_timeout_secs, Some(1200));
}
#[test]
fn test_invalid_ffi_backend_is_clear_config_error() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("mobench.toml");
std::fs::write(
&config_path,
r#"
[project]
crate = "test-bench"
ffi_backend = "jni-magic"
"#,
)
.unwrap();
let error = load_ffi_backend_from_file(&config_path).unwrap_err();
let message = error.to_string();
assert!(message.contains("Failed to parse config file"));
assert!(format!("{error:?}").contains("unknown variant `jni-magic`"));
let error = MobenchConfig::load_from_file(&config_path).unwrap_err();
assert!(error.to_string().contains("Failed to parse config file"));
}
#[test]
fn test_discover_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("mobench.toml");
let toml_content = r#"
[project]
crate = "discovered-bench"
"#;
std::fs::write(&config_path, toml_content).unwrap();
let result = MobenchConfig::discover_from(temp_dir.path()).unwrap();
assert!(result.is_some());
let (config, path) = result.unwrap();
assert_eq!(
config.project.crate_name,
Some("discovered-bench".to_string())
);
assert_eq!(path, config_path);
}
#[test]
fn test_discover_no_config() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
let result = MobenchConfig::discover_from(temp_dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_config_resolver() {
let config = MobenchConfig::starter("test-crate");
let resolver = ConfigResolver {
config: Some(config),
config_path: None,
};
let result = resolver.resolve(Some(200), |c| Some(c.benchmarks.default_iterations), 50);
assert_eq!(result, 200);
let result: u32 = resolver.resolve(None, |c| Some(c.benchmarks.default_iterations), 50);
assert_eq!(result, 100);
}
#[test]
fn test_config_resolver_loads_ffi_backend_from_config_path() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("mobench.toml");
std::fs::write(
&config_path,
r#"
[project]
crate = "test-bench"
ffi_backend = "native-c-abi"
"#,
)
.unwrap();
let config = MobenchConfig::load_from_file(&config_path).unwrap();
let resolver = ConfigResolver {
config: Some(config),
config_path: Some(config_path),
};
assert_eq!(resolver.ffi_backend(), mobench_sdk::FfiBackend::NativeCAbi);
}
#[test]
fn test_generate_starter_toml() {
let toml = MobenchConfig::generate_starter_toml("my-bench");
assert!(toml.contains("crate = \"my-bench\""));
assert!(toml.contains("library_name = \"my_bench\""));
assert!(toml.contains("ffi_backend = \"uniffi\""));
assert!(toml.contains("min_sdk = 24"));
assert!(toml.contains("target_sdk = 34"));
assert!(toml.contains("deployment_target = \"15.0\""));
assert!(toml.contains("default_iterations = 100"));
assert!(toml.contains("default_warmup = 10"));
assert!(toml.contains("[browserstack]"));
assert!(toml.contains("ios_completion_timeout_secs"));
}
}