use anyhow::{Result, bail};
use serde::Serialize;
use serde_json::{Value, json};
use std::env;
use std::path::{Path, PathBuf};
use crate::{
BenchConfig, CheckOutputFormat, ContractErrorCategory, SdkTarget, command_version_line,
filter_devices_by_tags, load_config, load_device_matrix, resolve_browserstack_credentials,
};
pub(crate) fn cmd_check(target: SdkTarget, format: CheckOutputFormat) -> Result<()> {
let checks = collect_prereq_checks(target);
let issues = collect_issues(&checks);
match format {
CheckOutputFormat::Text => print_check_results_text(&checks, &issues),
CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?,
}
if issues.is_empty() {
Ok(())
} else {
bail!(
"{} issue(s) found. Fix them and run 'cargo mobench check --target {:?}' again.",
issues.len(),
target
)
}
}
pub(crate) fn cmd_config_validate(config_path: &Path, format: CheckOutputFormat) -> Result<()> {
let mut checks = Vec::new();
let mut config: Option<BenchConfig> = None;
match load_config(config_path) {
Ok(cfg) => {
checks.push(PrereqCheck {
name: "Run config".to_string(),
passed: true,
detail: Some(config_path.display().to_string()),
fix_hint: None,
});
config = Some(cfg);
}
Err(err) => {
checks.push(PrereqCheck {
name: "Run config".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some(format!(
"Fix config file syntax/fields at {}",
config_path.display()
)),
});
}
}
if let Some(cfg) = &config {
match load_device_matrix(&cfg.device_matrix) {
Ok(matrix) => {
if let Some(tags) = cfg.device_tags.as_ref().filter(|tags| !tags.is_empty()) {
if let Err(err) = filter_devices_by_tags(matrix.devices, tags) {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some(format!(
"Update tags in {} or adjust device_tags in config",
cfg.device_matrix.display()
)),
});
} else {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: true,
detail: Some(format!(
"{} (tags: {})",
cfg.device_matrix.display(),
tags.join(", ")
)),
fix_hint: None,
});
}
} else {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: true,
detail: Some(cfg.device_matrix.display().to_string()),
fix_hint: None,
});
}
}
Err(err) => {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some(format!(
"Fix or regenerate device matrix at {}",
cfg.device_matrix.display()
)),
});
}
}
match resolve_browserstack_credentials(Some(&cfg.browserstack)) {
Ok(creds) => checks.push(PrereqCheck {
name: "BrowserStack credentials".to_string(),
passed: true,
detail: Some(format!("user {}", creds.username)),
fix_hint: None,
}),
Err(err) => checks.push(PrereqCheck {
name: "BrowserStack credentials".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()),
}),
}
}
let issues = collect_issues(&checks);
match format {
CheckOutputFormat::Text => print_check_results_text(&checks, &issues),
CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?,
}
if issues.is_empty() {
Ok(())
} else {
bail!(
"{} issue(s) found. Fix them and rerun 'cargo mobench config validate'.",
issues.len()
)
}
}
pub(crate) fn cmd_doctor(
target: SdkTarget,
config_path: Option<&Path>,
device_matrix_path: Option<&Path>,
device_tags: Vec<String>,
browserstack: bool,
format: CheckOutputFormat,
) -> Result<()> {
let mut checks = collect_prereq_checks(target);
let mut config: Option<BenchConfig> = None;
if let Some(path) = config_path {
match load_config(path) {
Ok(cfg) => {
checks.push(PrereqCheck {
name: "Run config".to_string(),
passed: true,
detail: Some(path.display().to_string()),
fix_hint: None,
});
config = Some(cfg);
}
Err(err) => {
checks.push(PrereqCheck {
name: "Run config".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some(format!("Fix or regenerate config at {}", path.display())),
});
}
}
} else {
checks.push(PrereqCheck {
name: "Run config".to_string(),
passed: true,
detail: Some("skipped (no --config)".to_string()),
fix_hint: None,
});
}
let resolved_matrix_path = device_matrix_path
.map(PathBuf::from)
.or_else(|| config.as_ref().map(|cfg| cfg.device_matrix.clone()));
let resolved_tags = if !device_tags.is_empty() {
Some(device_tags)
} else {
config.as_ref().and_then(|cfg| cfg.device_tags.clone())
};
if resolved_matrix_path.is_none() && resolved_tags.as_ref().is_some_and(|tags| !tags.is_empty())
{
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: false,
detail: Some("device tags provided without a matrix file".to_string()),
fix_hint: Some(
"Provide --device-matrix or set device_matrix in the config".to_string(),
),
});
} else if let Some(path) = resolved_matrix_path.as_deref() {
match load_device_matrix(path) {
Ok(matrix) => {
if let Some(tags) = resolved_tags.as_ref().filter(|tags| !tags.is_empty()) {
if let Err(err) = filter_devices_by_tags(matrix.devices, tags) {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some(format!(
"Update tags in {} or adjust --device-tags",
path.display()
)),
});
} else {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: true,
detail: Some(format!("{} (tags: {})", path.display(), tags.join(", "))),
fix_hint: None,
});
}
} else {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: true,
detail: Some(path.display().to_string()),
fix_hint: None,
});
}
}
Err(err) => checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some(format!(
"Fix or regenerate device matrix at {}",
path.display()
)),
}),
}
} else {
checks.push(PrereqCheck {
name: "Device matrix".to_string(),
passed: true,
detail: Some("skipped (no --device-matrix)".to_string()),
fix_hint: None,
});
}
if browserstack {
let cfg_ref = config.as_ref().map(|cfg| &cfg.browserstack);
match resolve_browserstack_credentials(cfg_ref) {
Ok(creds) => checks.push(PrereqCheck {
name: "BrowserStack credentials".to_string(),
passed: true,
detail: Some(format!("user {}", creds.username)),
fix_hint: None,
}),
Err(err) => checks.push(PrereqCheck {
name: "BrowserStack credentials".to_string(),
passed: false,
detail: Some(err.to_string()),
fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()),
}),
}
} else {
checks.push(PrereqCheck {
name: "BrowserStack credentials".to_string(),
passed: true,
detail: Some("skipped (--browserstack=false)".to_string()),
fix_hint: None,
});
}
let issues = collect_issues(&checks);
match format {
CheckOutputFormat::Text => print_check_results_text(&checks, &issues),
CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?,
}
if issues.is_empty() {
Ok(())
} else {
bail!(
"{} issue(s) found. Fix them and rerun 'cargo mobench doctor'.",
issues.len()
)
}
}
pub(crate) fn collect_prereq_checks(target: SdkTarget) -> Vec<PrereqCheck> {
let mut checks: Vec<PrereqCheck> = Vec::new();
checks.push(check_cargo());
checks.push(check_rustup());
checks.push(check_rustc_msrv());
match target {
SdkTarget::Android => {
println!("Checking prerequisites for Android...\n");
extend_android_prereq_checks(&mut checks);
}
SdkTarget::Ios => {
println!("Checking prerequisites for iOS...\n");
checks.push(check_xcode());
checks.push(check_xcodegen());
checks.push(check_rust_target("aarch64-apple-ios"));
checks.push(check_rust_target("aarch64-apple-ios-sim"));
checks.push(check_rust_target("x86_64-apple-ios"));
}
SdkTarget::Both => {
println!("Checking prerequisites for Android and iOS...\n");
extend_android_prereq_checks(&mut checks);
checks.push(check_xcode());
checks.push(check_xcodegen());
checks.push(check_rust_target("aarch64-apple-ios"));
checks.push(check_rust_target("aarch64-apple-ios-sim"));
checks.push(check_rust_target("x86_64-apple-ios"));
}
}
checks
}
pub(crate) const DEFAULT_ANDROID_DOCTOR_RUST_TARGETS: &[&str] = &["aarch64-linux-android"];
pub(crate) const WORKSPACE_MSRV: &str = "1.85";
fn extend_android_prereq_checks(checks: &mut Vec<PrereqCheck>) {
checks.push(check_android_ndk_home());
checks.push(check_cargo_ndk());
for target in DEFAULT_ANDROID_DOCTOR_RUST_TARGETS {
checks.push(check_rust_target(target));
}
checks.push(check_jdk());
}
pub(crate) fn collect_issues(checks: &[PrereqCheck]) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
for check in checks {
if !check.passed
&& let Some(ref fix) = check.fix_hint
{
issues.push(ValidationIssue {
category: issue_category_for_check(check),
check: check.name.clone(),
detail: check.detail.clone(),
fix_hint: fix.clone(),
});
}
}
issues
}
fn issue_category_for_check(check: &PrereqCheck) -> ContractErrorCategory {
match check.name.as_str() {
"Run config" | "Device matrix" => ContractErrorCategory::Config,
"BrowserStack credentials" => ContractErrorCategory::Provider,
_ => ContractErrorCategory::Preflight,
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PrereqCheck {
pub(crate) name: String,
pub(crate) passed: bool,
pub(crate) detail: Option<String>,
pub(crate) fix_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ValidationIssue {
pub(crate) category: ContractErrorCategory,
pub(crate) check: String,
pub(crate) detail: Option<String>,
pub(crate) fix_hint: String,
}
pub(crate) fn print_check_results_text(checks: &[PrereqCheck], issues: &[ValidationIssue]) {
for check in checks {
let status = if check.passed { "\u{2713}" } else { "\u{2717}" };
let detail = check.detail.as_deref().unwrap_or("");
let category = if check.passed {
None
} else {
Some(issue_category_for_check(check))
};
if detail.is_empty() {
if let Some(category) = category {
println!("{} {} [{}]", status, check.name, category_slug(category));
} else {
println!("{} {}", status, check.name);
}
} else {
if let Some(category) = category {
println!(
"{} {} [{}] ({})",
status,
check.name,
category_slug(category),
detail
);
} else {
println!("{} {} ({})", status, check.name, detail);
}
}
}
if !issues.is_empty() {
println!("\nTo fix:");
for issue in issues {
println!(" * [{}] {}", category_slug(issue.category), issue.fix_hint);
}
println!();
let failed_count = checks.iter().filter(|c| !c.passed).count();
println!("{} issue(s) found.", failed_count);
} else {
println!("\nAll prerequisites satisfied!");
}
}
pub(crate) fn print_check_results_json(
checks: &[PrereqCheck],
issues: &[ValidationIssue],
) -> Result<()> {
let output = render_check_results_json(checks, issues);
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
pub(crate) fn render_check_results_json(
checks: &[PrereqCheck],
issues: &[ValidationIssue],
) -> Value {
json!({
"checks": checks,
"issues": issues,
"all_passed": checks.iter().all(|c| c.passed),
"passed_count": checks.iter().filter(|c| c.passed).count(),
"failed_count": checks.iter().filter(|c| !c.passed).count(),
})
}
pub(crate) fn category_slug(category: ContractErrorCategory) -> &'static str {
match category {
ContractErrorCategory::Config => "config_error",
ContractErrorCategory::Preflight => "preflight_error",
ContractErrorCategory::Provider => "provider_error",
ContractErrorCategory::Build => "build_error",
ContractErrorCategory::Benchmark => "benchmark_error",
}
}
fn check_cargo() -> PrereqCheck {
let result = std::process::Command::new("cargo")
.arg("--version")
.output();
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
PrereqCheck {
name: "cargo installed".to_string(),
passed: true,
detail: Some(version),
fix_hint: None,
}
}
_ => PrereqCheck {
name: "cargo installed".to_string(),
passed: false,
detail: None,
fix_hint: Some("Install Rust: https://rustup.rs".to_string()),
},
}
}
fn check_rustup() -> PrereqCheck {
let result = std::process::Command::new("rustup")
.arg("--version")
.output();
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
PrereqCheck {
name: "rustup installed".to_string(),
passed: true,
detail: Some(version),
fix_hint: None,
}
}
_ => PrereqCheck {
name: "rustup installed".to_string(),
passed: false,
detail: None,
fix_hint: Some("Install rustup: https://rustup.rs".to_string()),
},
}
}
fn check_rustc_msrv() -> PrereqCheck {
match command_version_line("rustc", &["--version"]) {
Some(version) if rustc_version_meets_msrv(&version, WORKSPACE_MSRV) => PrereqCheck {
name: "rustc MSRV".to_string(),
passed: true,
detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")),
fix_hint: None,
},
Some(version) => PrereqCheck {
name: "rustc MSRV".to_string(),
passed: false,
detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")),
fix_hint: Some(format!(
"Update Rust: rustup update stable (MSRV {WORKSPACE_MSRV})"
)),
},
None => PrereqCheck {
name: "rustc MSRV".to_string(),
passed: false,
detail: Some("could not run rustc --version".to_string()),
fix_hint: Some("Install Rust: https://rustup.rs".to_string()),
},
}
}
pub(crate) fn rustc_version_meets_msrv(version_line: &str, msrv: &str) -> bool {
let Some(actual) = parse_rust_version(version_line) else {
return false;
};
let Some(required) = parse_rust_version(msrv) else {
return false;
};
actual >= required
}
pub(crate) fn parse_rust_version(input: &str) -> Option<(u32, u32, u32)> {
let version = input
.split_whitespace()
.find(|part| part.chars().next().is_some_and(|ch| ch.is_ascii_digit()))?;
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts
.next()
.and_then(|part| part.split('-').next())
.unwrap_or("0")
.parse()
.ok()?;
Some((major, minor, patch))
}
fn check_android_ndk_home() -> PrereqCheck {
match env::var("ANDROID_NDK_HOME") {
Ok(path) if !path.is_empty() => {
let path_exists = Path::new(&path).exists();
if path_exists {
PrereqCheck {
name: "ANDROID_NDK_HOME set".to_string(),
passed: true,
detail: Some(path),
fix_hint: None,
}
} else {
PrereqCheck {
name: "ANDROID_NDK_HOME set".to_string(),
passed: false,
detail: Some(format!("path does not exist: {}", path)),
fix_hint: Some("Set ANDROID_NDK_HOME to a valid NDK path: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/<version>".to_string()),
}
}
}
_ => PrereqCheck {
name: "ANDROID_NDK_HOME set".to_string(),
passed: false,
detail: None,
fix_hint: Some(
"Set ANDROID_NDK_HOME: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/<version>"
.to_string(),
),
},
}
}
fn check_cargo_ndk() -> PrereqCheck {
let result = std::process::Command::new("cargo")
.args(["ndk", "--version"])
.output();
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
PrereqCheck {
name: "cargo-ndk installed".to_string(),
passed: true,
detail: Some(version),
fix_hint: None,
}
}
_ => PrereqCheck {
name: "cargo-ndk installed".to_string(),
passed: false,
detail: None,
fix_hint: Some("Install cargo-ndk: cargo install cargo-ndk".to_string()),
},
}
}
fn check_rust_target(target: &str) -> PrereqCheck {
let result = std::process::Command::new("rustup")
.args(["target", "list", "--installed"])
.output();
match result {
Ok(output) if output.status.success() => {
let installed = String::from_utf8_lossy(&output.stdout);
let has_target = installed.lines().any(|line| line.trim() == target);
if has_target {
PrereqCheck {
name: format!("Rust target: {}", target),
passed: true,
detail: None,
fix_hint: None,
}
} else {
PrereqCheck {
name: format!("Rust target: {}", target),
passed: false,
detail: Some("not installed".to_string()),
fix_hint: Some(format!("Install target: rustup target add {}", target)),
}
}
}
_ => PrereqCheck {
name: format!("Rust target: {}", target),
passed: false,
detail: Some("could not check".to_string()),
fix_hint: Some(format!("Install target: rustup target add {}", target)),
},
}
}
fn check_jdk() -> PrereqCheck {
let result = std::process::Command::new("java").arg("-version").output();
match result {
Ok(output) => {
let version_output = String::from_utf8_lossy(&output.stderr);
let version_line = version_output.lines().next().unwrap_or("");
if output.status.success() || !version_line.is_empty() {
PrereqCheck {
name: "JDK installed".to_string(),
passed: true,
detail: Some(version_line.trim().to_string()),
fix_hint: None,
}
} else {
PrereqCheck {
name: "JDK installed".to_string(),
passed: false,
detail: None,
fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()),
}
}
}
Err(_) => PrereqCheck {
name: "JDK installed".to_string(),
passed: false,
detail: None,
fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()),
},
}
}
fn check_xcode() -> PrereqCheck {
let result = std::process::Command::new("xcodebuild")
.arg("-version")
.output();
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
PrereqCheck {
name: "Xcode installed".to_string(),
passed: true,
detail: Some(version),
fix_hint: None,
}
}
_ => PrereqCheck {
name: "Xcode installed".to_string(),
passed: false,
detail: None,
fix_hint: Some(
"Install Xcode from the App Store or run: xcode-select --install".to_string(),
),
},
}
}
fn check_xcodegen() -> PrereqCheck {
let result = std::process::Command::new("xcodegen")
.arg("--version")
.output();
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
PrereqCheck {
name: "xcodegen installed".to_string(),
passed: true,
detail: Some(version),
fix_hint: None,
}
}
_ => PrereqCheck {
name: "xcodegen installed".to_string(),
passed: false,
detail: None,
fix_hint: Some("Install xcodegen: brew install xcodegen".to_string()),
},
}
}