Skip to main content

mobench_sdk/
codegen.rs

1//! Code generation and template management
2//!
3//! This module provides functionality for generating mobile app projects from
4//! embedded templates. It handles template parameterization and file generation.
5
6use 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 NATIVE_ANDROID_MAIN_ACTIVITY_TEMPLATE: &str =
16    include_str!("native_templates/android/MainActivity.kt.template");
17const NATIVE_IOS_BENCH_RUNNER_FFI_TEMPLATE: &str =
18    include_str!("native_templates/ios/BenchRunnerFFI.swift.template");
19const BOLTFFI_ANDROID_MAIN_ACTIVITY_TEMPLATE: &str =
20    include_str!("boltffi_templates/android/MainActivity.kt.template");
21const BOLTFFI_IOS_BENCH_RUNNER_FFI_TEMPLATE: &str =
22    include_str!("boltffi_templates/ios/BenchRunnerFFI.swift.template");
23pub const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
24const DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS: u64 = 1800;
25const DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS: u64 = 10;
26
27pub const DEFAULT_IOS_DEPLOYMENT_TARGET: &str = "15.0";
28pub const SWIFTUI_RUNNER_MIN_IOS: &str = "15.0";
29
30/// Supported generated iOS application runner templates.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum IosRunner {
33    /// Current SwiftUI runner. This is the default for iOS 15+.
34    Swiftui,
35    /// UIKit-based runner for legacy deployment targets.
36    UikitLegacy,
37}
38
39impl IosRunner {
40    pub fn parse(value: &str) -> Result<Self, BenchError> {
41        match value.trim().to_ascii_lowercase().as_str() {
42            "swiftui" => Ok(Self::Swiftui),
43            "uikit-legacy" | "uikit_legacy" => Ok(Self::UikitLegacy),
44            other => Err(BenchError::Build(format!(
45                "Unsupported iOS runner `{other}`. Supported values: swiftui, uikit-legacy"
46            ))),
47        }
48    }
49
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Swiftui => "swiftui",
53            Self::UikitLegacy => "uikit-legacy",
54        }
55    }
56}
57
58/// Parsed iOS deployment target used for explicit compatibility decisions.
59#[derive(Debug, Clone, Eq)]
60pub struct IosDeploymentTarget {
61    raw: String,
62    major: u16,
63    minor: u16,
64}
65
66impl PartialEq for IosDeploymentTarget {
67    fn eq(&self, other: &Self) -> bool {
68        (self.major, self.minor) == (other.major, other.minor)
69    }
70}
71
72impl Ord for IosDeploymentTarget {
73    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
74        (self.major, self.minor).cmp(&(other.major, other.minor))
75    }
76}
77
78impl PartialOrd for IosDeploymentTarget {
79    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
80        Some(self.cmp(other))
81    }
82}
83
84impl IosDeploymentTarget {
85    pub fn parse(raw: &str) -> Result<Self, BenchError> {
86        let raw = raw.trim();
87        if raw.is_empty() {
88            return Err(BenchError::Build(
89                "iOS deployment target must not be empty".to_string(),
90            ));
91        }
92
93        let mut parts = raw.split('.');
94        let major_raw = parts.next().unwrap_or_default();
95        let minor_raw = parts.next().unwrap_or("0");
96        if parts.next().is_some() {
97            return Err(BenchError::Build(format!(
98                "Invalid iOS deployment target `{raw}`. Expected VERSION like 15.0"
99            )));
100        }
101
102        Ok(Self {
103            raw: raw.to_string(),
104            major: parse_ios_version_part(raw, major_raw, "major")?,
105            minor: parse_ios_version_part(raw, minor_raw, "minor")?,
106        })
107    }
108
109    pub fn default_target() -> Self {
110        Self::parse(DEFAULT_IOS_DEPLOYMENT_TARGET)
111            .expect("default iOS deployment target should be valid")
112    }
113}
114
115fn parse_ios_version_part(raw: &str, part: &str, label: &str) -> Result<u16, BenchError> {
116    if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
117        return Err(BenchError::Build(format!(
118            "Invalid iOS deployment target `{raw}`: {label} version component must be numeric"
119        )));
120    }
121
122    part.parse::<u16>().map_err(|err| {
123        BenchError::Build(format!(
124            "Invalid iOS deployment target `{raw}`: failed to parse {label} component: {err}"
125        ))
126    })
127}
128
129impl std::fmt::Display for IosDeploymentTarget {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.write_str(&self.raw)
132    }
133}
134
135/// Fully resolved iOS project generation options.
136#[derive(Debug, Clone)]
137pub struct IosProjectOptions {
138    pub deployment_target: IosDeploymentTarget,
139    pub runner: IosRunner,
140    pub ios_benchmark_timeout_secs: u64,
141}
142
143impl Default for IosProjectOptions {
144    fn default() -> Self {
145        Self {
146            deployment_target: IosDeploymentTarget::default_target(),
147            runner: IosRunner::Swiftui,
148            ios_benchmark_timeout_secs: DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS,
149        }
150    }
151}
152
153pub fn resolve_ios_runner(
154    deployment_target: &IosDeploymentTarget,
155    requested_runner: Option<IosRunner>,
156) -> Result<IosRunner, BenchError> {
157    let swiftui_floor = IosDeploymentTarget::parse(SWIFTUI_RUNNER_MIN_IOS)?;
158    match requested_runner {
159        Some(IosRunner::Swiftui) if deployment_target < &swiftui_floor => {
160            Err(BenchError::Build(format!(
161                "iOS runner `swiftui` requires deployment target {SWIFTUI_RUNNER_MIN_IOS}+; \
162                 requested deployment target is {deployment_target}. Use `uikit-legacy` for older iOS targets."
163            )))
164        }
165        Some(runner) => Ok(runner),
166        None if deployment_target < &swiftui_floor => Ok(IosRunner::UikitLegacy),
167        None => Ok(IosRunner::Swiftui),
168    }
169}
170
171/// Template variable that can be replaced in template files
172#[derive(Debug, Clone)]
173pub struct TemplateVar {
174    pub name: &'static str,
175    pub value: String,
176}
177
178/// Generates a new mobile benchmark project from templates
179///
180/// Creates the necessary directory structure and files for benchmarking on
181/// mobile platforms. This includes:
182/// - A `bench-mobile/` crate for FFI bindings
183/// - Platform-specific app projects (Android and/or iOS)
184/// - Configuration files
185///
186/// # Arguments
187///
188/// * `config` - Configuration for project initialization
189///
190/// # Returns
191///
192/// * `Ok(PathBuf)` - Path to the generated project root
193/// * `Err(BenchError)` - If generation fails
194pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
195    let output_dir = &config.output_dir;
196    let project_slug = sanitize_package_name(&config.project_name);
197    let project_pascal = to_pascal_case(&project_slug);
198    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
199    let bundle_id_component = sanitize_bundle_id_component(&project_slug);
200    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
201
202    // Create base directories
203    fs::create_dir_all(output_dir)?;
204
205    // Generate bench-mobile FFI wrapper crate
206    generate_bench_mobile_crate(output_dir, &project_slug)?;
207
208    // For full project generation (init), use "example_fibonacci" as the default
209    // since the generated example benchmarks include this function
210    let default_function = "example_fibonacci";
211
212    // Generate platform-specific projects
213    match config.target {
214        Target::Android => {
215            generate_android_project(output_dir, &project_slug, default_function)?;
216        }
217        Target::Ios => {
218            generate_ios_project(
219                output_dir,
220                &project_slug,
221                &project_pascal,
222                &bundle_prefix,
223                default_function,
224            )?;
225        }
226        Target::Both => {
227            generate_android_project(output_dir, &project_slug, default_function)?;
228            generate_ios_project(
229                output_dir,
230                &project_slug,
231                &project_pascal,
232                &bundle_prefix,
233                default_function,
234            )?;
235        }
236    }
237
238    // Generate config file
239    generate_config_file(output_dir, config)?;
240
241    // Generate examples if requested
242    if config.generate_examples {
243        generate_example_benchmarks(output_dir)?;
244    }
245
246    Ok(output_dir.clone())
247}
248
249/// Generates the bench-mobile FFI wrapper crate
250fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
251    let crate_dir = output_dir.join("bench-mobile");
252    fs::create_dir_all(crate_dir.join("src"))?;
253
254    let crate_name = format!("{}-bench-mobile", project_name);
255
256    // Generate Cargo.toml
257    // Note: We configure rustls to use 'ring' instead of 'aws-lc-rs' (default in rustls 0.23+)
258    // because aws-lc-rs doesn't compile for Android NDK targets.
259    let cargo_toml = format!(
260        r#"[package]
261name = "{}"
262version = "0.1.0"
263edition = "2021"
264
265[lib]
266crate-type = ["cdylib", "staticlib", "rlib"]
267
268[dependencies]
269mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
270uniffi = "0.28"
271{} = {{ path = ".." }}
272
273[features]
274default = []
275
276[build-dependencies]
277uniffi = {{ version = "0.28", features = ["build"] }}
278
279# Binary for generating UniFFI bindings (used by mobench build)
280[[bin]]
281name = "uniffi-bindgen"
282path = "src/bin/uniffi-bindgen.rs"
283
284# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
285# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
286# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
287#
288# Add this to your root Cargo.toml:
289# [workspace.dependencies]
290# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
291#
292# Then in each crate that uses rustls:
293# [dependencies]
294# rustls = {{ workspace = true }}
295"#,
296        crate_name, project_name
297    );
298
299    fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
300
301    // Generate src/lib.rs
302    let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
303//!
304//! This crate provides the FFI boundary between Rust benchmarks and mobile
305//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
306
307use uniffi;
308
309// Ensure the user crate is linked so benchmark registrations are pulled in.
310extern crate {{USER_CRATE}} as _bench_user_crate;
311
312// Re-export mobench-sdk types with UniFFI annotations
313#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
314pub struct BenchSpec {
315    pub name: String,
316    pub iterations: u32,
317    pub warmup: u32,
318}
319
320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
321pub struct BenchSample {
322    pub duration_ns: u64,
323    pub cpu_time_ms: Option<u64>,
324    pub peak_memory_kb: Option<u64>,
325    pub process_peak_memory_kb: Option<u64>,
326}
327
328#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
329pub struct SemanticPhase {
330    pub name: String,
331    pub duration_ns: u64,
332}
333
334#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
335pub struct HarnessTimelineSpan {
336    pub phase: String,
337    pub start_offset_ns: u64,
338    pub end_offset_ns: u64,
339    pub iteration: Option<u32>,
340}
341
342#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
343pub struct BenchReport {
344    pub spec: BenchSpec,
345    pub samples: Vec<BenchSample>,
346    pub phases: Vec<SemanticPhase>,
347    pub timeline: Vec<HarnessTimelineSpan>,
348}
349
350#[derive(Debug, thiserror::Error, uniffi::Error)]
351#[uniffi(flat_error)]
352pub enum BenchError {
353    #[error("iterations must be greater than zero")]
354    InvalidIterations,
355
356    #[error("unknown benchmark function: {name}")]
357    UnknownFunction { name: String },
358
359    #[error("benchmark execution failed: {reason}")]
360    ExecutionFailed { reason: String },
361}
362
363// Convert from mobench-sdk types
364impl From<mobench_sdk::BenchSpec> for BenchSpec {
365    fn from(spec: mobench_sdk::BenchSpec) -> Self {
366        Self {
367            name: spec.name,
368            iterations: spec.iterations,
369            warmup: spec.warmup,
370        }
371    }
372}
373
374impl From<BenchSpec> for mobench_sdk::BenchSpec {
375    fn from(spec: BenchSpec) -> Self {
376        Self {
377            name: spec.name,
378            iterations: spec.iterations,
379            warmup: spec.warmup,
380        }
381    }
382}
383
384impl From<mobench_sdk::BenchSample> for BenchSample {
385    fn from(sample: mobench_sdk::BenchSample) -> Self {
386        Self {
387            duration_ns: sample.duration_ns,
388            cpu_time_ms: sample.cpu_time_ms,
389            peak_memory_kb: sample.peak_memory_kb,
390            process_peak_memory_kb: sample.process_peak_memory_kb,
391        }
392    }
393}
394
395impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
396    fn from(phase: mobench_sdk::SemanticPhase) -> Self {
397        Self {
398            name: phase.name,
399            duration_ns: phase.duration_ns,
400        }
401    }
402}
403
404impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
405    fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
406        Self {
407            phase: span.phase,
408            start_offset_ns: span.start_offset_ns,
409            end_offset_ns: span.end_offset_ns,
410            iteration: span.iteration,
411        }
412    }
413}
414
415impl From<mobench_sdk::RunnerReport> for BenchReport {
416    fn from(report: mobench_sdk::RunnerReport) -> Self {
417        Self {
418            spec: report.spec.into(),
419            samples: report.samples.into_iter().map(Into::into).collect(),
420            phases: report.phases.into_iter().map(Into::into).collect(),
421            timeline: report.timeline.into_iter().map(Into::into).collect(),
422        }
423    }
424}
425
426impl From<mobench_sdk::BenchError> for BenchError {
427    fn from(err: mobench_sdk::BenchError) -> Self {
428        match err {
429            mobench_sdk::BenchError::Runner(runner_err) => {
430                BenchError::ExecutionFailed {
431                    reason: runner_err.to_string(),
432                }
433            }
434            mobench_sdk::BenchError::UnknownFunction(name, _available) => {
435                BenchError::UnknownFunction { name }
436            }
437            _ => BenchError::ExecutionFailed {
438                reason: err.to_string(),
439            },
440        }
441    }
442}
443
444/// Runs a benchmark by name with the given specification
445///
446/// This is the main FFI entry point called from mobile platforms.
447#[uniffi::export]
448pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
449    let sdk_spec: mobench_sdk::BenchSpec = spec.into();
450    let report = mobench_sdk::run_benchmark(sdk_spec)?;
451    Ok(report.into())
452}
453
454// Generate UniFFI scaffolding
455uniffi::setup_scaffolding!();
456"#;
457
458    let lib_rs = render_template(
459        lib_rs_template,
460        &[TemplateVar {
461            name: "USER_CRATE",
462            value: project_name.replace('-', "_"),
463        }],
464    );
465    fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
466
467    // Generate build.rs
468    let build_rs = r#"fn main() {
469    uniffi::generate_scaffolding("src/lib.rs").unwrap();
470}
471"#;
472
473    fs::write(crate_dir.join("build.rs"), build_rs)?;
474
475    // Generate uniffi-bindgen binary (used by mobench build)
476    let bin_dir = crate_dir.join("src/bin");
477    fs::create_dir_all(&bin_dir)?;
478    let uniffi_bindgen_rs = r#"fn main() {
479    uniffi::uniffi_bindgen_main()
480}
481"#;
482    fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
483
484    Ok(())
485}
486
487/// Generates Android project structure from templates
488///
489/// This function can be called standalone to generate just the Android
490/// project scaffolding, useful for auto-generation during build.
491///
492/// # Arguments
493///
494/// * `output_dir` - Directory to write the `android/` project into
495/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
496/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
497pub fn generate_android_project(
498    output_dir: &Path,
499    project_slug: &str,
500    default_function: &str,
501) -> Result<(), BenchError> {
502    generate_android_project_with_backend(
503        output_dir,
504        project_slug,
505        default_function,
506        crate::FfiBackend::Uniffi,
507    )
508}
509
510/// Generates Android project structure from templates for a specific FFI backend.
511pub fn generate_android_project_with_backend(
512    output_dir: &Path,
513    project_slug: &str,
514    default_function: &str,
515    ffi_backend: crate::FfiBackend,
516) -> Result<(), BenchError> {
517    let android_benchmark_timeout_secs = resolve_positive_u64_env(
518        "MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS",
519        DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS,
520    );
521    let android_heartbeat_interval_secs = resolve_positive_u64_env(
522        "MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS",
523        DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
524    );
525    generate_android_project_with_options(
526        output_dir,
527        project_slug,
528        default_function,
529        ffi_backend,
530        android_benchmark_timeout_secs,
531        android_heartbeat_interval_secs,
532    )
533}
534
535fn generate_android_project_with_options(
536    output_dir: &Path,
537    project_slug: &str,
538    default_function: &str,
539    ffi_backend: crate::FfiBackend,
540    android_benchmark_timeout_secs: u64,
541    android_heartbeat_interval_secs: u64,
542) -> Result<(), BenchError> {
543    let target_dir = output_dir.join("android");
544    reset_generated_project_dir(&target_dir)?;
545    let library_name = project_slug.replace('-', "_");
546    let project_pascal = to_pascal_case(project_slug);
547    // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS
548    // This ensures both platforms use the same naming convention: "benchmobile" not "bench-mobile"
549    let package_id_component = sanitize_bundle_id_component(project_slug);
550    let package_name = format!("dev.world.{}", package_id_component);
551    let boltffi_kotlin_package = format!("com.mobench.{}", package_id_component);
552    let vars = vec![
553        TemplateVar {
554            name: "PROJECT_NAME",
555            value: project_slug.to_string(),
556        },
557        TemplateVar {
558            name: "PROJECT_NAME_PASCAL",
559            value: project_pascal.clone(),
560        },
561        TemplateVar {
562            name: "APP_NAME",
563            value: format!("{} Benchmark", project_pascal),
564        },
565        TemplateVar {
566            name: "PACKAGE_NAME",
567            value: package_name.clone(),
568        },
569        TemplateVar {
570            name: "BOLTFFI_KOTLIN_PACKAGE",
571            value: boltffi_kotlin_package.clone(),
572        },
573        TemplateVar {
574            name: "UNIFFI_NAMESPACE",
575            value: library_name.clone(),
576        },
577        TemplateVar {
578            name: "LIBRARY_NAME",
579            value: library_name,
580        },
581        TemplateVar {
582            name: "DEFAULT_FUNCTION",
583            value: default_function.to_string(),
584        },
585        TemplateVar {
586            name: "ANDROID_BENCHMARK_TIMEOUT_SECS",
587            value: android_benchmark_timeout_secs.to_string(),
588        },
589        TemplateVar {
590            name: "ANDROID_HEARTBEAT_INTERVAL_SECS",
591            value: android_heartbeat_interval_secs.to_string(),
592        },
593    ];
594    render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
595
596    // Move Kotlin files to the correct package directory structure
597    // The package "dev.world.{project_slug}" maps to directory "dev/world/{project_slug}/"
598    move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
599    match ffi_backend {
600        crate::FfiBackend::Uniffi => {}
601        crate::FfiBackend::NativeCAbi => {
602            write_native_android_main_activity(&target_dir, &package_name, &vars)?;
603        }
604        crate::FfiBackend::BoltFfi => {
605            write_boltffi_android_main_activity(&target_dir, &package_name, &vars)?;
606            write_boltffi_config(
607                output_dir,
608                project_slug,
609                "BenchRunner",
610                &boltffi_kotlin_package,
611            )?;
612        }
613    }
614
615    Ok(())
616}
617
618fn write_native_android_main_activity(
619    target_dir: &Path,
620    package_name: &str,
621    vars: &[TemplateVar],
622) -> Result<(), BenchError> {
623    let relative_package = package_name.replace('.', "/");
624    let path = target_dir
625        .join("app/src/main/java")
626        .join(relative_package)
627        .join("MainActivity.kt");
628    let rendered = render_template(NATIVE_ANDROID_MAIN_ACTIVITY_TEMPLATE, vars);
629    validate_no_unreplaced_placeholders(&rendered, Path::new("MainActivity.kt"))?;
630    fs::write(&path, rendered).map_err(BenchError::Io)
631}
632
633fn write_boltffi_android_main_activity(
634    target_dir: &Path,
635    package_name: &str,
636    vars: &[TemplateVar],
637) -> Result<(), BenchError> {
638    let relative_package = package_name.replace('.', "/");
639    let path = target_dir
640        .join("app/src/main/java")
641        .join(relative_package)
642        .join("MainActivity.kt");
643    let rendered = render_template(BOLTFFI_ANDROID_MAIN_ACTIVITY_TEMPLATE, vars);
644    validate_no_unreplaced_placeholders(&rendered, Path::new("MainActivity.kt"))?;
645    fs::write(&path, rendered).map_err(BenchError::Io)
646}
647
648fn collect_preserved_files(
649    root: &Path,
650    current: &Path,
651    preserved: &mut Vec<(PathBuf, Vec<u8>)>,
652) -> Result<(), BenchError> {
653    let mut entries = fs::read_dir(current)?
654        .collect::<Result<Vec<_>, _>>()
655        .map_err(BenchError::Io)?;
656    entries.sort_by_key(|entry| entry.path());
657
658    for entry in entries {
659        let path = entry.path();
660        if path.is_dir() {
661            collect_preserved_files(root, &path, preserved)?;
662            continue;
663        }
664
665        let relative = path.strip_prefix(root).map_err(|e| {
666            BenchError::Build(format!(
667                "Failed to preserve generated resource {:?}: {}",
668                path, e
669            ))
670        })?;
671        preserved.push((relative.to_path_buf(), fs::read(&path)?));
672    }
673
674    Ok(())
675}
676
677fn collect_preserved_ios_resources(
678    target_dir: &Path,
679) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
680    let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
681    let mut preserved = Vec::new();
682
683    if resources_dir.exists() {
684        collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
685    }
686
687    Ok(preserved)
688}
689
690fn restore_preserved_ios_resources(
691    target_dir: &Path,
692    preserved_resources: &[(PathBuf, Vec<u8>)],
693) -> Result<(), BenchError> {
694    if preserved_resources.is_empty() {
695        return Ok(());
696    }
697
698    let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
699    for (relative, contents) in preserved_resources {
700        let resource_path = resources_dir.join(relative);
701        if let Some(parent) = resource_path.parent() {
702            fs::create_dir_all(parent)?;
703        }
704        fs::write(resource_path, contents)?;
705    }
706
707    Ok(())
708}
709
710fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
711    if target_dir.exists() {
712        fs::remove_dir_all(target_dir).map_err(|e| {
713            BenchError::Build(format!(
714                "Failed to clear existing generated project at {:?}: {}",
715                target_dir, e
716            ))
717        })?;
718    }
719    Ok(())
720}
721
722/// Moves Kotlin source files to the correct package directory structure
723///
724/// Android requires source files to be in directories matching their package declaration.
725/// For example, a file with `package dev.world.my_project` must be in
726/// `app/src/main/java/dev/world/my_project/`.
727///
728/// This function moves:
729/// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/`
730/// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/`
731fn move_kotlin_files_to_package_dir(
732    android_dir: &Path,
733    package_name: &str,
734) -> Result<(), BenchError> {
735    // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project")
736    let package_path = package_name.replace('.', "/");
737
738    // Move main source files
739    let main_java_dir = android_dir.join("app/src/main/java");
740    let main_package_dir = main_java_dir.join(&package_path);
741    move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
742
743    // Move test source files
744    let test_java_dir = android_dir.join("app/src/androidTest/java");
745    let test_package_dir = test_java_dir.join(&package_path);
746    move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
747
748    Ok(())
749}
750
751/// Moves a single Kotlin file from source directory to package directory
752fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
753    let src_file = src_dir.join(filename);
754    if !src_file.exists() {
755        // File doesn't exist in source, nothing to move
756        return Ok(());
757    }
758
759    // Create the package directory if it doesn't exist
760    fs::create_dir_all(dest_dir).map_err(|e| {
761        BenchError::Build(format!(
762            "Failed to create package directory {:?}: {}",
763            dest_dir, e
764        ))
765    })?;
766
767    let dest_file = dest_dir.join(filename);
768
769    // Move the file (copy + delete for cross-filesystem compatibility)
770    fs::copy(&src_file, &dest_file).map_err(|e| {
771        BenchError::Build(format!(
772            "Failed to copy {} to {:?}: {}",
773            filename, dest_file, e
774        ))
775    })?;
776
777    fs::remove_file(&src_file).map_err(|e| {
778        BenchError::Build(format!(
779            "Failed to remove original file {:?}: {}",
780            src_file, e
781        ))
782    })?;
783
784    Ok(())
785}
786
787/// Generates iOS project structure from templates
788///
789/// This function can be called standalone to generate just the iOS
790/// project scaffolding, useful for auto-generation during build.
791///
792/// # Arguments
793///
794/// * `output_dir` - Directory to write the `ios/` project into
795/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
796/// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile")
797/// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench")
798/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
799pub fn generate_ios_project(
800    output_dir: &Path,
801    project_slug: &str,
802    project_pascal: &str,
803    bundle_prefix: &str,
804    default_function: &str,
805) -> Result<(), BenchError> {
806    generate_ios_project_with_backend_options(
807        output_dir,
808        project_slug,
809        project_pascal,
810        bundle_prefix,
811        default_function,
812        crate::FfiBackend::Uniffi,
813        IosProjectOptions {
814            ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs(
815                std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
816                    .ok()
817                    .as_deref(),
818            ),
819            ..IosProjectOptions::default()
820        },
821    )
822}
823
824/// Generates iOS project structure from templates for a specific FFI backend.
825pub fn generate_ios_project_with_backend(
826    output_dir: &Path,
827    project_slug: &str,
828    project_pascal: &str,
829    bundle_prefix: &str,
830    default_function: &str,
831    ffi_backend: crate::FfiBackend,
832) -> Result<(), BenchError> {
833    generate_ios_project_with_backend_options(
834        output_dir,
835        project_slug,
836        project_pascal,
837        bundle_prefix,
838        default_function,
839        ffi_backend,
840        IosProjectOptions {
841            ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs(
842                std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
843                    .ok()
844                    .as_deref(),
845            ),
846            ..IosProjectOptions::default()
847        },
848    )
849}
850
851#[cfg(test)]
852fn generate_ios_project_with_timeout(
853    output_dir: &Path,
854    project_slug: &str,
855    project_pascal: &str,
856    bundle_prefix: &str,
857    default_function: &str,
858    ios_benchmark_timeout_secs: u64,
859    ffi_backend: crate::FfiBackend,
860) -> Result<(), BenchError> {
861    generate_ios_project_with_backend_options(
862        output_dir,
863        project_slug,
864        project_pascal,
865        bundle_prefix,
866        default_function,
867        ffi_backend,
868        IosProjectOptions {
869            ios_benchmark_timeout_secs,
870            ..IosProjectOptions::default()
871        },
872    )
873}
874
875pub fn generate_ios_project_with_backend_options(
876    output_dir: &Path,
877    project_slug: &str,
878    project_pascal: &str,
879    bundle_prefix: &str,
880    default_function: &str,
881    ffi_backend: crate::FfiBackend,
882    options: IosProjectOptions,
883) -> Result<(), BenchError> {
884    let runner = resolve_ios_runner(&options.deployment_target, Some(options.runner))?;
885    let target_dir = output_dir.join("ios");
886    let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
887    reset_generated_project_dir(&target_dir)?;
888    // Sanitize bundle ID components to ensure they only contain alphanumeric characters
889    // iOS bundle identifiers should not contain hyphens or underscores
890    let sanitized_bundle_prefix = {
891        let parts: Vec<&str> = bundle_prefix.split('.').collect();
892        parts
893            .iter()
894            .map(|part| sanitize_bundle_id_component(part))
895            .collect::<Vec<_>>()
896            .join(".")
897    };
898    // Use the actual app name (project_pascal, e.g., "BenchRunner") for the bundle ID suffix,
899    // not the crate name again. This prevents duplication like "dev.world.benchmobile.benchmobile"
900    // and produces the correct "dev.world.benchmobile.BenchRunner"
901    let vars = vec![
902        TemplateVar {
903            name: "DEFAULT_FUNCTION",
904            value: default_function.to_string(),
905        },
906        TemplateVar {
907            name: "PROJECT_NAME_PASCAL",
908            value: project_pascal.to_string(),
909        },
910        TemplateVar {
911            name: "BUNDLE_ID_PREFIX",
912            value: sanitized_bundle_prefix.clone(),
913        },
914        TemplateVar {
915            name: "BUNDLE_ID",
916            value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
917        },
918        TemplateVar {
919            name: "LIBRARY_NAME",
920            value: project_slug.replace('-', "_"),
921        },
922        TemplateVar {
923            name: "IOS_BENCHMARK_TIMEOUT_SECS",
924            value: options.ios_benchmark_timeout_secs.to_string(),
925        },
926        TemplateVar {
927            name: "IOS_DEPLOYMENT_TARGET",
928            value: options.deployment_target.to_string(),
929        },
930        TemplateVar {
931            name: "IOS_RUNNER",
932            value: runner.as_str().to_string(),
933        },
934        TemplateVar {
935            name: "BRIDGING_HEADER_IMPORTS",
936            value: match ffi_backend {
937                crate::FfiBackend::BoltFfi => String::new(),
938                _ => format!("#import \"{}FFI.h\"", project_slug.replace('-', "_")),
939            },
940        },
941    ];
942    render_ios_dir(&IOS_TEMPLATES, &target_dir, &vars, runner)?;
943    match ffi_backend {
944        crate::FfiBackend::Uniffi => {}
945        crate::FfiBackend::NativeCAbi => {
946            write_native_ios_bench_runner_ffi(&target_dir, project_pascal, &vars)?;
947        }
948        crate::FfiBackend::BoltFfi => {
949            write_boltffi_ios_bench_runner_ffi(&target_dir, project_pascal, &vars)?;
950            write_boltffi_config(
951                output_dir,
952                project_slug,
953                project_pascal,
954                "dev.world.benchrunner",
955            )?;
956        }
957    }
958    restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
959    Ok(())
960}
961
962fn write_native_ios_bench_runner_ffi(
963    target_dir: &Path,
964    project_pascal: &str,
965    vars: &[TemplateVar],
966) -> Result<(), BenchError> {
967    let app_dir = target_dir.join("BenchRunner").join(project_pascal);
968    let path = app_dir.join("BenchRunnerFFI.swift");
969    let generated_dir = app_dir.join("Generated");
970    fs::create_dir_all(&generated_dir).map_err(BenchError::Io)?;
971    let library_name = vars
972        .iter()
973        .find(|var| var.name == "LIBRARY_NAME")
974        .map(|var| var.value.as_str())
975        .unwrap_or("bench_mobile");
976    let header_path = generated_dir.join(format!("{library_name}FFI.h"));
977    fs::write(&header_path, native_c_abi_header(library_name)).map_err(BenchError::Io)?;
978
979    let rendered = render_template(NATIVE_IOS_BENCH_RUNNER_FFI_TEMPLATE, vars);
980    validate_no_unreplaced_placeholders(&rendered, Path::new("BenchRunnerFFI.swift"))?;
981    fs::write(&path, rendered).map_err(BenchError::Io)
982}
983
984fn write_boltffi_ios_bench_runner_ffi(
985    target_dir: &Path,
986    project_pascal: &str,
987    vars: &[TemplateVar],
988) -> Result<(), BenchError> {
989    let app_dir = target_dir.join("BenchRunner").join(project_pascal);
990    let path = app_dir.join("BenchRunnerFFI.swift");
991    let generated_dir = app_dir.join("Generated").join("BoltFFIGenerated");
992    fs::create_dir_all(&generated_dir).map_err(BenchError::Io)?;
993
994    let rendered = render_template(BOLTFFI_IOS_BENCH_RUNNER_FFI_TEMPLATE, vars);
995    validate_no_unreplaced_placeholders(&rendered, Path::new("BenchRunnerFFI.swift"))?;
996    fs::write(&path, rendered).map_err(BenchError::Io)
997}
998
999pub fn write_boltffi_config(
1000    project_root: &Path,
1001    library_name: &str,
1002    swift_module_name: &str,
1003    kotlin_package: &str,
1004) -> Result<(), BenchError> {
1005    write_boltffi_config_with_options(
1006        project_root,
1007        library_name,
1008        library_name,
1009        swift_module_name,
1010        kotlin_package,
1011        &["arm64".to_string()],
1012    )
1013}
1014
1015pub fn write_boltffi_config_with_options(
1016    project_root: &Path,
1017    package_name: &str,
1018    crate_name: &str,
1019    swift_module_name: &str,
1020    kotlin_package: &str,
1021    android_architectures: &[String],
1022) -> Result<(), BenchError> {
1023    write_boltffi_config_with_paths(
1024        project_root,
1025        package_name,
1026        crate_name,
1027        swift_module_name,
1028        kotlin_package,
1029        android_architectures,
1030        &BoltFfiOutputPaths::default(),
1031    )
1032}
1033
1034pub fn write_boltffi_config_with_output_dir(
1035    project_root: &Path,
1036    package_name: &str,
1037    crate_name: &str,
1038    swift_module_name: &str,
1039    kotlin_package: &str,
1040    android_architectures: &[String],
1041    output_dir: &Path,
1042) -> Result<(), BenchError> {
1043    write_boltffi_config_with_paths(
1044        project_root,
1045        package_name,
1046        crate_name,
1047        swift_module_name,
1048        kotlin_package,
1049        android_architectures,
1050        &BoltFfiOutputPaths::for_output_dir(output_dir),
1051    )
1052}
1053
1054struct BoltFfiOutputPaths {
1055    android_kotlin: String,
1056    android_header: String,
1057    android_pack: String,
1058    apple_swift: String,
1059    apple_header: String,
1060    apple_xcframework: String,
1061}
1062
1063impl BoltFfiOutputPaths {
1064    fn default() -> Self {
1065        Self {
1066            android_kotlin: "target/mobench/android/app/src/main/java".to_string(),
1067            android_header: "target/mobench/boltffi/android/include".to_string(),
1068            android_pack: "target/mobench/android/app/src/main/jniLibs".to_string(),
1069            apple_swift: "target/mobench/ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated"
1070                .to_string(),
1071            apple_header: "target/mobench/ios/include".to_string(),
1072            apple_xcframework: "target/mobench/ios".to_string(),
1073        }
1074    }
1075
1076    fn for_output_dir(output_dir: &Path) -> Self {
1077        Self {
1078            android_kotlin: toml_path(output_dir.join("android/app/src/main/java")),
1079            android_header: toml_path(output_dir.join("boltffi/android/include")),
1080            android_pack: toml_path(output_dir.join("android/app/src/main/jniLibs")),
1081            apple_swift: toml_path(
1082                output_dir.join("ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated"),
1083            ),
1084            apple_header: toml_path(output_dir.join("ios/include")),
1085            apple_xcframework: toml_path(output_dir.join("ios")),
1086        }
1087    }
1088}
1089
1090fn toml_path(path: PathBuf) -> String {
1091    path.to_string_lossy().replace('\\', "\\\\")
1092}
1093
1094fn write_boltffi_config_with_paths(
1095    project_root: &Path,
1096    package_name: &str,
1097    crate_name: &str,
1098    swift_module_name: &str,
1099    kotlin_package: &str,
1100    android_architectures: &[String],
1101    paths: &BoltFfiOutputPaths,
1102) -> Result<(), BenchError> {
1103    let library_name = crate_name.replace('-', "_");
1104    let android_architectures = android_architectures
1105        .iter()
1106        .map(|arch| format!("\"{arch}\""))
1107        .collect::<Vec<_>>()
1108        .join(", ");
1109    let content = format!(
1110        r#"[package]
1111name = "{package_name}"
1112crate = "{crate_name}"
1113
1114[targets.android]
1115enabled = true
1116architectures = [{android_architectures}]
1117
1118[targets.android.kotlin]
1119package = "{kotlin_package}"
1120output = "{android_kotlin}"
1121library_name = "{library_name}"
1122desktop_loader = "system"
1123api_style = "top_level"
1124
1125[targets.android.header]
1126output = "{android_header}"
1127
1128[targets.android.pack]
1129output = "{android_pack}"
1130
1131[targets.apple]
1132enabled = true
1133include_macos = false
1134
1135[targets.apple.swift]
1136module_name = "{swift_module_name}"
1137output = "{apple_swift}"
1138ffi_module_name = "{library_name}FFI"
1139
1140[targets.apple.header]
1141output = "{apple_header}"
1142
1143[targets.apple.xcframework]
1144output = "{apple_xcframework}"
1145name = "{library_name}"
1146
1147[targets.apple.spm]
1148layout = "split"
1149skip_package_swift = true
1150"#,
1151        android_kotlin = paths.android_kotlin,
1152        android_header = paths.android_header,
1153        android_pack = paths.android_pack,
1154        apple_swift = paths.apple_swift,
1155        apple_header = paths.apple_header,
1156        apple_xcframework = paths.apple_xcframework,
1157    );
1158    fs::write(project_root.join("boltffi.toml"), content).map_err(BenchError::Io)
1159}
1160
1161fn native_c_abi_header(framework_name: &str) -> String {
1162    let guard = format!(
1163        "{}_MOBENCH_NATIVE_C_ABI_H",
1164        framework_name.to_ascii_uppercase()
1165    )
1166    .replace('-', "_");
1167    format!(
1168        r#"#ifndef {guard}
1169#define {guard}
1170
1171#include <stdint.h>
1172#include <stddef.h>
1173
1174#ifdef __cplusplus
1175extern "C" {{
1176#endif
1177
1178typedef struct MobenchBuf {{
1179    uint8_t *ptr;
1180    uintptr_t len;
1181    uintptr_t cap;
1182}} MobenchBuf;
1183
1184int32_t mobench_run_benchmark_json(const uint8_t *spec_ptr, uintptr_t spec_len, MobenchBuf *out);
1185void mobench_free_buf(MobenchBuf *buf);
1186const char *mobench_last_error_message(void);
1187
1188#ifdef __cplusplus
1189}}
1190#endif
1191
1192#endif
1193"#
1194    )
1195}
1196
1197fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
1198    value
1199        .and_then(|raw| raw.parse::<u64>().ok())
1200        .filter(|secs| *secs > 0)
1201        .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
1202}
1203
1204fn resolve_positive_u64_env(name: &str, default: u64) -> u64 {
1205    std::env::var(name)
1206        .ok()
1207        .and_then(|raw| raw.parse::<u64>().ok())
1208        .filter(|secs| *secs > 0)
1209        .unwrap_or(default)
1210}
1211
1212/// Generates bench-config.toml configuration file
1213fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
1214    let config_target = match config.target {
1215        Target::Ios => "ios",
1216        Target::Android | Target::Both => "android",
1217    };
1218    let config_content = format!(
1219        r#"# mobench configuration
1220# This file controls how benchmarks are executed on devices.
1221
1222target = "{}"
1223function = "example_fibonacci"
1224iterations = 100
1225warmup = 10
1226device_matrix = "device-matrix.yaml"
1227device_tags = ["default"]
1228
1229[browserstack]
1230app_automate_username = "${{BROWSERSTACK_USERNAME}}"
1231app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
1232project = "{}-benchmarks"
1233
1234[ios_xcuitest]
1235app = "target/ios/BenchRunner.ipa"
1236test_suite = "target/ios/BenchRunnerUITests.zip"
1237"#,
1238        config_target, config.project_name
1239    );
1240
1241    fs::write(output_dir.join("bench-config.toml"), config_content)?;
1242
1243    Ok(())
1244}
1245
1246/// Generates example benchmark functions
1247fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
1248    let examples_dir = output_dir.join("benches");
1249    fs::create_dir_all(&examples_dir)?;
1250
1251    let example_content = r#"//! Example benchmarks
1252//!
1253//! This file demonstrates how to write benchmarks with mobench-sdk.
1254
1255use mobench_sdk::benchmark;
1256
1257/// Simple benchmark example
1258#[benchmark]
1259fn example_fibonacci() {
1260    let result = fibonacci(30);
1261    std::hint::black_box(result);
1262}
1263
1264/// Another example with a loop
1265#[benchmark]
1266fn example_sum() {
1267    let mut sum = 0u64;
1268    for i in 0..10000 {
1269        sum = sum.wrapping_add(i);
1270    }
1271    std::hint::black_box(sum);
1272}
1273
1274// Helper function (not benchmarked)
1275fn fibonacci(n: u32) -> u64 {
1276    match n {
1277        0 => 0,
1278        1 => 1,
1279        _ => {
1280            let mut a = 0u64;
1281            let mut b = 1u64;
1282            for _ in 2..=n {
1283                let next = a.wrapping_add(b);
1284                a = b;
1285                b = next;
1286            }
1287            b
1288        }
1289    }
1290}
1291"#;
1292
1293    fs::write(examples_dir.join("example.rs"), example_content)?;
1294
1295    Ok(())
1296}
1297
1298/// File extensions that should be processed for template variable substitution
1299const TEMPLATE_EXTENSIONS: &[&str] = &[
1300    "gradle",
1301    "xml",
1302    "kt",
1303    "java",
1304    "swift",
1305    "yml",
1306    "yaml",
1307    "json",
1308    "toml",
1309    "md",
1310    "txt",
1311    "h",
1312    "m",
1313    "plist",
1314    "pbxproj",
1315    "xcscheme",
1316    "xcworkspacedata",
1317    "entitlements",
1318    "modulemap",
1319];
1320
1321fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
1322    render_dir_filtered(dir, out_root, vars, &|_| false)
1323}
1324
1325fn render_ios_dir(
1326    dir: &Dir,
1327    out_root: &Path,
1328    vars: &[TemplateVar],
1329    runner: IosRunner,
1330) -> Result<(), BenchError> {
1331    render_dir_filtered(dir, out_root, vars, &|path| match runner {
1332        IosRunner::Swiftui => {
1333            path == Path::new("BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template")
1334        }
1335        IosRunner::UikitLegacy => {
1336            path == Path::new("BenchRunner/BenchRunner/BenchRunnerApp.swift.template")
1337                || path == Path::new("BenchRunner/BenchRunner/ContentView.swift.template")
1338        }
1339    })
1340}
1341
1342fn render_dir_filtered(
1343    dir: &Dir,
1344    out_root: &Path,
1345    vars: &[TemplateVar],
1346    skip_file: &dyn Fn(&Path) -> bool,
1347) -> Result<(), BenchError> {
1348    for entry in dir.entries() {
1349        match entry {
1350            DirEntry::Dir(sub) => {
1351                // Skip cache directories
1352                if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
1353                    continue;
1354                }
1355                render_dir_filtered(sub, out_root, vars, skip_file)?;
1356            }
1357            DirEntry::File(file) => {
1358                if file.path().components().any(|c| c.as_os_str() == ".gradle") {
1359                    continue;
1360                }
1361                if skip_file(file.path()) {
1362                    continue;
1363                }
1364                // file.path() returns the full relative path from the embedded dir root
1365                let mut relative = file.path().to_path_buf();
1366                let mut contents = file.contents().to_vec();
1367
1368                // Check if file has .template extension (explicit template)
1369                let is_explicit_template = relative
1370                    .extension()
1371                    .map(|ext| ext == "template")
1372                    .unwrap_or(false);
1373
1374                // Check if file is a text file that should be processed for templates
1375                let should_render = is_explicit_template || is_template_file(&relative);
1376
1377                if is_explicit_template {
1378                    // Remove .template extension from output filename
1379                    relative.set_extension("");
1380                }
1381
1382                if should_render && let Ok(text) = std::str::from_utf8(&contents) {
1383                    let rendered = render_template(text, vars);
1384                    // Validate that all template variables were replaced
1385                    validate_no_unreplaced_placeholders(&rendered, &relative)?;
1386                    contents = rendered.into_bytes();
1387                }
1388
1389                let out_path = out_root.join(relative);
1390                if let Some(parent) = out_path.parent() {
1391                    fs::create_dir_all(parent)?;
1392                }
1393                fs::write(&out_path, contents)?;
1394            }
1395        }
1396    }
1397    Ok(())
1398}
1399
1400/// Checks if a file should be processed for template variable substitution
1401/// based on its extension
1402fn is_template_file(path: &Path) -> bool {
1403    // Check for .template extension on any file
1404    if let Some(ext) = path.extension() {
1405        if ext == "template" {
1406            return true;
1407        }
1408        // Check if the base extension is in our list
1409        if let Some(ext_str) = ext.to_str() {
1410            return TEMPLATE_EXTENSIONS.contains(&ext_str);
1411        }
1412    }
1413    // Also check the filename without the .template extension
1414    if let Some(stem) = path.file_stem() {
1415        let stem_path = Path::new(stem);
1416        if let Some(ext) = stem_path.extension()
1417            && let Some(ext_str) = ext.to_str()
1418        {
1419            return TEMPLATE_EXTENSIONS.contains(&ext_str);
1420        }
1421    }
1422    false
1423}
1424
1425/// Validates that no unreplaced template placeholders remain in the rendered content
1426fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
1427    // Find all {{...}} patterns
1428    let mut pos = 0;
1429    let mut unreplaced = Vec::new();
1430
1431    while let Some(start) = content[pos..].find("{{") {
1432        let abs_start = pos + start;
1433        if let Some(end) = content[abs_start..].find("}}") {
1434            let placeholder = &content[abs_start..abs_start + end + 2];
1435            // Extract just the variable name
1436            let var_name = &content[abs_start + 2..abs_start + end];
1437            // Skip placeholders that look like Gradle variable syntax (e.g., ${...})
1438            // or other non-template patterns
1439            if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
1440                unreplaced.push(placeholder.to_string());
1441            }
1442            pos = abs_start + end + 2;
1443        } else {
1444            break;
1445        }
1446    }
1447
1448    if !unreplaced.is_empty() {
1449        return Err(BenchError::Build(format!(
1450            "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
1451             This is a bug in mobench-sdk. Please report it at:\n\
1452             https://github.com/worldcoin/mobile-bench-rs/issues",
1453            file_path, unreplaced
1454        )));
1455    }
1456
1457    Ok(())
1458}
1459
1460fn render_template(input: &str, vars: &[TemplateVar]) -> String {
1461    let mut output = input.to_string();
1462    for var in vars {
1463        output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
1464    }
1465    output
1466}
1467
1468/// Sanitizes a string to be a valid iOS bundle identifier component
1469///
1470/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9),
1471/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency,
1472/// this function converts all non-alphanumeric characters to lowercase letters only.
1473///
1474/// Examples:
1475/// - "bench-mobile" -> "benchmobile"
1476/// - "bench_mobile" -> "benchmobile"
1477/// - "my-project_name" -> "myprojectname"
1478pub fn sanitize_bundle_id_component(name: &str) -> String {
1479    name.chars()
1480        .filter(|c| c.is_ascii_alphanumeric())
1481        .collect::<String>()
1482        .to_lowercase()
1483}
1484
1485fn sanitize_package_name(name: &str) -> String {
1486    name.chars()
1487        .map(|c| {
1488            if c.is_ascii_alphanumeric() {
1489                c.to_ascii_lowercase()
1490            } else {
1491                '-'
1492            }
1493        })
1494        .collect::<String>()
1495        .trim_matches('-')
1496        .replace("--", "-")
1497}
1498
1499/// Converts a string to PascalCase
1500pub fn to_pascal_case(input: &str) -> String {
1501    input
1502        .split(|c: char| !c.is_ascii_alphanumeric())
1503        .filter(|s| !s.is_empty())
1504        .map(|s| {
1505            let mut chars = s.chars();
1506            let first = chars.next().unwrap().to_ascii_uppercase();
1507            let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
1508            format!("{}{}", first, rest)
1509        })
1510        .collect::<String>()
1511}
1512
1513/// Checks if the Android project scaffolding exists at the given output directory
1514///
1515/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists.
1516pub fn android_project_exists(output_dir: &Path) -> bool {
1517    let android_dir = output_dir.join("android");
1518    android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
1519}
1520
1521/// Checks if the iOS project scaffolding exists at the given output directory
1522///
1523/// Returns true if the `ios/BenchRunner/project.yml` file exists.
1524pub fn ios_project_exists(output_dir: &Path) -> bool {
1525    output_dir.join("ios/BenchRunner/project.yml").exists()
1526}
1527
1528/// Checks whether an existing iOS project was generated for the given library name.
1529///
1530/// Returns `false` if the xcframework reference in `project.yml` doesn't match,
1531/// which means the project needs to be regenerated for the new crate.
1532fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1533    let project_yml = output_dir.join("ios/BenchRunner/project.yml");
1534    let Ok(content) = std::fs::read_to_string(&project_yml) else {
1535        return false;
1536    };
1537    let expected = format!("../{}.xcframework", library_name);
1538    content.contains(&expected)
1539}
1540
1541/// Checks whether an existing Android project was generated for the given library name.
1542///
1543/// Returns `false` if the JNI library name in `build.gradle` doesn't match,
1544/// which means the project needs to be regenerated for the new crate.
1545fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1546    let build_gradle = output_dir.join("android/app/build.gradle");
1547    let Ok(content) = std::fs::read_to_string(&build_gradle) else {
1548        return false;
1549    };
1550    let expected = format!("lib{}.so", library_name);
1551    content.contains(&expected)
1552}
1553
1554/// Checks whether an existing Android runner was generated for the selected FFI backend.
1555fn android_project_matches_backend(output_dir: &Path, ffi_backend: crate::FfiBackend) -> bool {
1556    let Some(main_activity) = read_android_main_activity(output_dir) else {
1557        return false;
1558    };
1559
1560    match ffi_backend {
1561        crate::FfiBackend::Uniffi => {
1562            main_activity.contains("uniffi.") || main_activity.contains("runBenchmark(spec")
1563        }
1564        crate::FfiBackend::NativeCAbi => {
1565            main_activity.contains("mobench_run_benchmark_json")
1566                && main_activity.contains("com.sun.jna")
1567        }
1568        crate::FfiBackend::BoltFfi => main_activity.contains("runBenchmarkJson(specJson"),
1569    }
1570}
1571
1572fn read_android_main_activity(output_dir: &Path) -> Option<String> {
1573    let java_root = output_dir.join("android/app/src/main/java");
1574    let mut stack = vec![java_root];
1575    let mut content = String::new();
1576
1577    while let Some(dir) = stack.pop() {
1578        let entries = std::fs::read_dir(dir).ok()?;
1579        for entry in entries.flatten() {
1580            let path = entry.path();
1581            if path.is_dir() {
1582                stack.push(path);
1583            } else if path.file_name().and_then(|name| name.to_str()) == Some("MainActivity.kt") {
1584                let file_content = std::fs::read_to_string(path).ok()?;
1585                content.push_str(&file_content);
1586                content.push('\n');
1587            }
1588        }
1589    }
1590
1591    if content.is_empty() {
1592        None
1593    } else {
1594        Some(content)
1595    }
1596}
1597
1598/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
1599///
1600/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1601/// the first one found in the format `{crate_name}::{function_name}`.
1602///
1603/// # Arguments
1604///
1605/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1606/// * `crate_name` - Name of the crate (used as prefix for the function name)
1607///
1608/// # Returns
1609///
1610/// * `Some(String)` - The detected function name in format `crate_name::function_name`
1611/// * `None` - If no benchmark functions are found or if the file cannot be read
1612pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
1613    let lib_rs = crate_dir.join("src/lib.rs");
1614    if !lib_rs.exists() {
1615        return None;
1616    }
1617
1618    let file = fs::File::open(&lib_rs).ok()?;
1619    let reader = BufReader::new(file);
1620
1621    let mut found_benchmark_attr = false;
1622    let crate_name_normalized = crate_name.replace('-', "_");
1623
1624    for line in reader.lines().map_while(Result::ok) {
1625        let trimmed = line.trim();
1626
1627        // Check for #[benchmark] attribute
1628        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1629            found_benchmark_attr = true;
1630            continue;
1631        }
1632
1633        // If we found a benchmark attribute, look for the function definition
1634        if found_benchmark_attr {
1635            // Look for "fn function_name" or "pub fn function_name"
1636            if let Some(fn_pos) = trimmed.find("fn ") {
1637                let after_fn = &trimmed[fn_pos + 3..];
1638                // Extract function name (until '(' or whitespace)
1639                let fn_name: String = after_fn
1640                    .chars()
1641                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1642                    .collect();
1643
1644                if !fn_name.is_empty() {
1645                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
1646                }
1647            }
1648            // Reset if we hit a line that's not a function definition
1649            // (could be another attribute or comment)
1650            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1651                found_benchmark_attr = false;
1652            }
1653        }
1654    }
1655
1656    None
1657}
1658
1659/// Detects all benchmark functions in a crate by scanning src/lib.rs for `#[benchmark]`
1660///
1661/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1662/// all found in the format `{crate_name}::{function_name}`.
1663///
1664/// # Arguments
1665///
1666/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1667/// * `crate_name` - Name of the crate (used as prefix for the function names)
1668///
1669/// # Returns
1670///
1671/// A vector of benchmark function names in format `crate_name::function_name`
1672pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1673    let lib_rs = crate_dir.join("src/lib.rs");
1674    if !lib_rs.exists() {
1675        return Vec::new();
1676    }
1677
1678    let Ok(file) = fs::File::open(&lib_rs) else {
1679        return Vec::new();
1680    };
1681    let reader = BufReader::new(file);
1682
1683    let mut benchmarks = Vec::new();
1684    let mut found_benchmark_attr = false;
1685    let crate_name_normalized = crate_name.replace('-', "_");
1686
1687    for line in reader.lines().map_while(Result::ok) {
1688        let trimmed = line.trim();
1689
1690        // Check for #[benchmark] attribute
1691        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1692            found_benchmark_attr = true;
1693            continue;
1694        }
1695
1696        // If we found a benchmark attribute, look for the function definition
1697        if found_benchmark_attr {
1698            // Look for "fn function_name" or "pub fn function_name"
1699            if let Some(fn_pos) = trimmed.find("fn ") {
1700                let after_fn = &trimmed[fn_pos + 3..];
1701                // Extract function name (until '(' or whitespace)
1702                let fn_name: String = after_fn
1703                    .chars()
1704                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1705                    .collect();
1706
1707                if !fn_name.is_empty() {
1708                    benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1709                }
1710                found_benchmark_attr = false;
1711            }
1712            // Reset if we hit a line that's not a function definition
1713            // (could be another attribute or comment)
1714            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1715                found_benchmark_attr = false;
1716            }
1717        }
1718    }
1719
1720    benchmarks
1721}
1722
1723/// Validates that a benchmark function exists in the crate source
1724///
1725/// # Arguments
1726///
1727/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1728/// * `crate_name` - Name of the crate (used as prefix for the function names)
1729/// * `function_name` - The function name to validate (with or without crate prefix)
1730///
1731/// # Returns
1732///
1733/// `true` if the function is found, `false` otherwise
1734pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1735    let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1736    let crate_name_normalized = crate_name.replace('-', "_");
1737
1738    // Normalize the function name - add crate prefix if missing
1739    let normalized_name = if function_name.contains("::") {
1740        function_name.to_string()
1741    } else {
1742        format!("{}::{}", crate_name_normalized, function_name)
1743    };
1744
1745    benchmarks.iter().any(|b| b == &normalized_name)
1746}
1747
1748/// Resolves the default benchmark function for a project
1749///
1750/// This function attempts to auto-detect benchmark functions from the crate's source.
1751/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
1752///
1753/// # Arguments
1754///
1755/// * `project_root` - Root directory of the project
1756/// * `crate_name` - Name of the benchmark crate
1757/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
1758///
1759/// # Returns
1760///
1761/// The default function name in format `crate_name::function_name`
1762pub fn resolve_default_function(
1763    project_root: &Path,
1764    crate_name: &str,
1765    crate_dir: Option<&Path>,
1766) -> String {
1767    let crate_name_normalized = crate_name.replace('-', "_");
1768
1769    // Try to find the crate directory
1770    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1771        vec![dir.to_path_buf()]
1772    } else {
1773        vec![
1774            project_root.join("bench-mobile"),
1775            project_root.join("crates").join(crate_name),
1776            project_root.to_path_buf(),
1777        ]
1778    };
1779
1780    // Try to detect benchmarks from each potential location
1781    for dir in &search_dirs {
1782        if dir.join("Cargo.toml").exists()
1783            && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1784        {
1785            return detected;
1786        }
1787    }
1788
1789    // Fallback: use a sensible default based on crate name
1790    format!("{}::example_benchmark", crate_name_normalized)
1791}
1792
1793/// Auto-generates Android project scaffolding from a crate name
1794///
1795/// This is a convenience function that derives template variables from the
1796/// crate name and generates the Android project structure. It auto-detects
1797/// the default benchmark function from the crate's source code.
1798///
1799/// # Arguments
1800///
1801/// * `output_dir` - Directory to write the `android/` project into
1802/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1803pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1804    ensure_android_project_with_options(output_dir, crate_name, None, None)
1805}
1806
1807/// Auto-generates Android project scaffolding with additional options
1808///
1809/// This is a more flexible version of `ensure_android_project` that allows
1810/// specifying a custom default function and/or crate directory.
1811///
1812/// # Arguments
1813///
1814/// * `output_dir` - Directory to write the `android/` project into
1815/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1816/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1817/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1818pub fn ensure_android_project_with_options(
1819    output_dir: &Path,
1820    crate_name: &str,
1821    project_root: Option<&Path>,
1822    crate_dir: Option<&Path>,
1823) -> Result<(), BenchError> {
1824    ensure_android_project_with_backend_options(
1825        output_dir,
1826        crate_name,
1827        project_root,
1828        crate_dir,
1829        crate::FfiBackend::Uniffi,
1830    )
1831}
1832
1833/// Auto-generates Android project scaffolding for the selected FFI backend.
1834pub fn ensure_android_project_with_backend_options(
1835    output_dir: &Path,
1836    crate_name: &str,
1837    project_root: Option<&Path>,
1838    crate_dir: Option<&Path>,
1839    ffi_backend: crate::FfiBackend,
1840) -> Result<(), BenchError> {
1841    let library_name = crate_name.replace('-', "_");
1842    let project_exists = android_project_exists(output_dir);
1843    let project_matches_library = android_project_matches_library(output_dir, &library_name);
1844    let project_matches_backend = android_project_matches_backend(output_dir, ffi_backend);
1845
1846    if project_exists && !project_matches_library {
1847        println!(
1848            "Existing Android scaffolding does not match library, regenerating for {} backend...",
1849            ffi_backend
1850        );
1851    } else if project_exists && !project_matches_backend {
1852        println!(
1853            "Existing Android scaffolding does not match FFI backend, regenerating for {} backend...",
1854            ffi_backend
1855        );
1856    } else if project_exists {
1857        println!(
1858            "Refreshing generated Android scaffolding for {} backend...",
1859            ffi_backend
1860        );
1861    } else {
1862        println!(
1863            "Android project not found, generating scaffolding for {} backend...",
1864            ffi_backend
1865        );
1866    }
1867
1868    let project_slug = crate_name.replace('-', "_");
1869
1870    // Resolve the default function by auto-detecting from source
1871    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1872    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1873
1874    generate_android_project_with_backend(
1875        output_dir,
1876        &project_slug,
1877        &default_function,
1878        ffi_backend,
1879    )?;
1880    println!(
1881        "  Generated Android project at {:?}",
1882        output_dir.join("android")
1883    );
1884    println!("  Default benchmark function: {}", default_function);
1885    Ok(())
1886}
1887
1888/// Auto-generates iOS project scaffolding from a crate name
1889///
1890/// This is a convenience function that derives template variables from the
1891/// crate name and generates the iOS project structure. It auto-detects
1892/// the default benchmark function from the crate's source code.
1893///
1894/// # Arguments
1895///
1896/// * `output_dir` - Directory to write the `ios/` project into
1897/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1898pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1899    ensure_ios_project_with_options(output_dir, crate_name, None, None)
1900}
1901
1902/// Auto-generates iOS project scaffolding with additional options
1903///
1904/// This is a more flexible version of `ensure_ios_project` that allows
1905/// specifying a custom default function and/or crate directory.
1906///
1907/// # Arguments
1908///
1909/// * `output_dir` - Directory to write the `ios/` project into
1910/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1911/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1912/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1913pub fn ensure_ios_project_with_options(
1914    output_dir: &Path,
1915    crate_name: &str,
1916    project_root: Option<&Path>,
1917    crate_dir: Option<&Path>,
1918) -> Result<(), BenchError> {
1919    ensure_ios_project_with_backend_options(
1920        output_dir,
1921        crate_name,
1922        project_root,
1923        crate_dir,
1924        crate::FfiBackend::Uniffi,
1925        IosProjectOptions::default(),
1926    )
1927}
1928
1929/// Auto-generates iOS project scaffolding for the selected FFI backend.
1930pub fn ensure_ios_project_with_backend_options(
1931    output_dir: &Path,
1932    crate_name: &str,
1933    project_root: Option<&Path>,
1934    crate_dir: Option<&Path>,
1935    ffi_backend: crate::FfiBackend,
1936    options: IosProjectOptions,
1937) -> Result<(), BenchError> {
1938    let library_name = crate_name.replace('-', "_");
1939    let project_exists = ios_project_exists(output_dir);
1940    let project_matches = ios_project_matches_library(output_dir, &library_name);
1941    if project_exists && !project_matches {
1942        println!(
1943            "Existing iOS scaffolding does not match library, regenerating for {} backend...",
1944            ffi_backend
1945        );
1946    } else if project_exists {
1947        println!(
1948            "Refreshing generated iOS scaffolding for {} backend...",
1949            ffi_backend
1950        );
1951    } else {
1952        println!(
1953            "iOS project not found, generating scaffolding for {} backend...",
1954            ffi_backend
1955        );
1956    }
1957
1958    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
1959    let project_pascal = "BenchRunner";
1960    // Derive library name and bundle prefix from crate name
1961    let library_name = crate_name.replace('-', "_");
1962    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
1963    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
1964    let bundle_id_component = sanitize_bundle_id_component(crate_name);
1965    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1966
1967    // Resolve the default function by auto-detecting from source
1968    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1969    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1970
1971    generate_ios_project_with_backend_options(
1972        output_dir,
1973        &library_name,
1974        project_pascal,
1975        &bundle_prefix,
1976        &default_function,
1977        ffi_backend,
1978        options,
1979    )?;
1980    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
1981    println!("  Default benchmark function: {}", default_function);
1982    Ok(())
1983}
1984
1985#[cfg(test)]
1986mod tests {
1987    use super::*;
1988    use std::env;
1989
1990    #[test]
1991    fn test_generate_bench_mobile_crate() {
1992        let temp_dir = env::temp_dir().join("mobench-sdk-test");
1993        fs::create_dir_all(&temp_dir).unwrap();
1994
1995        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1996        assert!(result.is_ok());
1997
1998        // Verify files were created
1999        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
2000        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
2001        assert!(temp_dir.join("bench-mobile/build.rs").exists());
2002        let cargo_toml =
2003            fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
2004        assert!(
2005            cargo_toml.contains(
2006                r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
2007            ),
2008            "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
2009        );
2010
2011        // Cleanup
2012        fs::remove_dir_all(&temp_dir).ok();
2013    }
2014
2015    #[test]
2016    fn test_generate_android_project_no_unreplaced_placeholders() {
2017        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
2018        // Clean up any previous test run
2019        let _ = fs::remove_dir_all(&temp_dir);
2020        fs::create_dir_all(&temp_dir).unwrap();
2021
2022        let result =
2023            generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
2024        assert!(
2025            result.is_ok(),
2026            "generate_android_project failed: {:?}",
2027            result.err()
2028        );
2029
2030        // Verify key files exist
2031        let android_dir = temp_dir.join("android");
2032        assert!(android_dir.join("settings.gradle").exists());
2033        assert!(android_dir.join("app/build.gradle").exists());
2034        assert!(
2035            android_dir
2036                .join("app/src/main/AndroidManifest.xml")
2037                .exists()
2038        );
2039        assert!(
2040            android_dir
2041                .join("app/src/main/res/values/strings.xml")
2042                .exists()
2043        );
2044        assert!(
2045            android_dir
2046                .join("app/src/main/res/values/themes.xml")
2047                .exists()
2048        );
2049
2050        // Verify no unreplaced placeholders remain in generated files
2051        let files_to_check = [
2052            "settings.gradle",
2053            "app/build.gradle",
2054            "app/src/main/AndroidManifest.xml",
2055            "app/src/main/res/values/strings.xml",
2056            "app/src/main/res/values/themes.xml",
2057        ];
2058
2059        for file in files_to_check {
2060            let path = android_dir.join(file);
2061            let contents =
2062                fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
2063
2064            // Check for unreplaced placeholders
2065            let has_placeholder = contents.contains("{{") && contents.contains("}}");
2066            assert!(
2067                !has_placeholder,
2068                "File {} contains unreplaced template placeholders: {}",
2069                file, contents
2070            );
2071        }
2072
2073        // Verify specific substitutions were made
2074        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
2075        assert!(
2076            settings.contains("my-bench-project-android")
2077                || settings.contains("my_bench_project-android"),
2078            "settings.gradle should contain project name"
2079        );
2080
2081        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
2082        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
2083        assert!(
2084            build_gradle.contains("dev.world.mybenchproject"),
2085            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
2086        );
2087        assert!(
2088            !build_gradle.contains("testBuildType \"release\""),
2089            "debug builds should be able to produce assembleDebugAndroidTest"
2090        );
2091        assert!(
2092            build_gradle.contains("mobenchTestBuildType"),
2093            "release builds should be able to request assembleReleaseAndroidTest"
2094        );
2095
2096        let manifest =
2097            fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
2098        assert!(
2099            manifest.contains("Theme.MyBenchProject"),
2100            "AndroidManifest.xml should contain PascalCase theme name"
2101        );
2102
2103        let strings =
2104            fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
2105        assert!(
2106            strings.contains("Benchmark"),
2107            "strings.xml should contain app name with Benchmark"
2108        );
2109
2110        // Verify Kotlin files are in the correct package directory structure
2111        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
2112        let main_activity_path =
2113            android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
2114        assert!(
2115            main_activity_path.exists(),
2116            "MainActivity.kt should be in package directory: {:?}",
2117            main_activity_path
2118        );
2119
2120        let test_activity_path = android_dir
2121            .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
2122        assert!(
2123            test_activity_path.exists(),
2124            "MainActivityTest.kt should be in package directory: {:?}",
2125            test_activity_path
2126        );
2127
2128        // Verify the files are NOT in the root java directory
2129        assert!(
2130            !android_dir
2131                .join("app/src/main/java/MainActivity.kt")
2132                .exists(),
2133            "MainActivity.kt should not be in root java directory"
2134        );
2135        assert!(
2136            !android_dir
2137                .join("app/src/androidTest/java/MainActivityTest.kt")
2138                .exists(),
2139            "MainActivityTest.kt should not be in root java directory"
2140        );
2141
2142        // Cleanup
2143        fs::remove_dir_all(&temp_dir).ok();
2144    }
2145
2146    #[test]
2147    fn test_generate_android_project_replaces_previous_package_tree() {
2148        let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
2149        let _ = fs::remove_dir_all(&temp_dir);
2150        fs::create_dir_all(&temp_dir).unwrap();
2151
2152        generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
2153            .unwrap();
2154        let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
2155        assert!(
2156            old_package_dir.exists(),
2157            "expected first package tree to exist"
2158        );
2159
2160        generate_android_project(
2161            &temp_dir,
2162            "basic_benchmark",
2163            "basic_benchmark::bench_fibonacci",
2164        )
2165        .unwrap();
2166
2167        let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
2168        assert!(
2169            new_package_dir.exists(),
2170            "expected new package tree to exist"
2171        );
2172        assert!(
2173            !old_package_dir.exists(),
2174            "old package tree should be removed when regenerating the Android scaffold"
2175        );
2176
2177        fs::remove_dir_all(&temp_dir).ok();
2178    }
2179
2180    #[test]
2181    fn test_generate_android_native_backend_runner_template() {
2182        let temp_dir = env::temp_dir().join("mobench-sdk-android-native-test");
2183        let _ = fs::remove_dir_all(&temp_dir);
2184        fs::create_dir_all(&temp_dir).unwrap();
2185
2186        generate_android_project_with_backend(
2187            &temp_dir,
2188            "native_benchmark",
2189            "native_benchmark::bench_prove",
2190            crate::FfiBackend::NativeCAbi,
2191        )
2192        .unwrap();
2193
2194        let main_activity = fs::read_to_string(
2195            temp_dir.join("android/app/src/main/java/dev/world/nativebenchmark/MainActivity.kt"),
2196        )
2197        .unwrap();
2198        assert!(main_activity.contains("com.sun.jna.Native"));
2199        assert!(main_activity.contains("mobench_run_benchmark_json"));
2200        assert!(main_activity.contains("BENCH_JSON"));
2201        assert!(main_activity.contains("bench_spec.json"));
2202        assert!(
2203            !main_activity.contains("uniffi."),
2204            "native Android runner must not import UniFFI bindings:\n{}",
2205            main_activity
2206        );
2207        assert!(
2208            !main_activity.contains("runBenchmark("),
2209            "native Android runner must call the JSON C ABI, not UniFFI runBenchmark"
2210        );
2211
2212        fs::remove_dir_all(&temp_dir).ok();
2213    }
2214
2215    #[test]
2216    fn test_generate_android_boltffi_backend_runner_template() {
2217        let temp_dir = env::temp_dir().join("mobench-sdk-android-boltffi-test");
2218        let _ = fs::remove_dir_all(&temp_dir);
2219        fs::create_dir_all(&temp_dir).unwrap();
2220
2221        generate_android_project_with_backend(
2222            &temp_dir,
2223            "bolt_benchmark",
2224            "bolt_benchmark::bench_prove",
2225            crate::FfiBackend::BoltFfi,
2226        )
2227        .unwrap();
2228
2229        let main_activity = fs::read_to_string(
2230            temp_dir.join("android/app/src/main/java/dev/world/boltbenchmark/MainActivity.kt"),
2231        )
2232        .unwrap();
2233        assert!(main_activity.contains("import com.mobench.boltbenchmark.runBenchmarkJson"));
2234        assert!(main_activity.contains("runBenchmarkJson(specJson = spec.toString())"));
2235        assert!(main_activity.contains("fun isBenchmarkComplete()"));
2236        assert!(main_activity.contains("BENCH_JSON"));
2237        assert!(
2238            !main_activity.contains("uniffi."),
2239            "BoltFFI Android runner must not import UniFFI bindings:\n{}",
2240            main_activity
2241        );
2242        assert!(
2243            !main_activity.contains("com.sun.jna"),
2244            "BoltFFI Android runner must not use the native C ABI JNA bridge"
2245        );
2246
2247        let boltffi_toml = fs::read_to_string(temp_dir.join("boltffi.toml")).unwrap();
2248        assert!(boltffi_toml.contains("name = \"bolt_benchmark\""));
2249        assert!(boltffi_toml.contains("crate = \"bolt_benchmark\""));
2250        assert!(boltffi_toml.contains("package = \"com.mobench.boltbenchmark\""));
2251        assert!(boltffi_toml.contains("desktop_loader = \"system\""));
2252        assert!(boltffi_toml.contains("output = \"target/mobench/android/app/src/main/java\""));
2253
2254        fs::remove_dir_all(&temp_dir).ok();
2255    }
2256
2257    #[test]
2258    fn test_ensure_android_project_regenerates_when_ffi_backend_changes() {
2259        let temp_dir = env::temp_dir().join("mobench-sdk-android-backend-switch-test");
2260        let _ = fs::remove_dir_all(&temp_dir);
2261        fs::create_dir_all(&temp_dir).unwrap();
2262
2263        generate_android_project_with_backend(
2264            &temp_dir,
2265            "switch_benchmark",
2266            "switch_benchmark::bench_prove",
2267            crate::FfiBackend::Uniffi,
2268        )
2269        .unwrap();
2270
2271        let main_activity_path =
2272            temp_dir.join("android/app/src/main/java/dev/world/switchbenchmark/MainActivity.kt");
2273        let uniffi_main = fs::read_to_string(&main_activity_path).unwrap();
2274        assert!(uniffi_main.contains("uniffi."));
2275
2276        ensure_android_project_with_backend_options(
2277            &temp_dir,
2278            "switch_benchmark",
2279            None,
2280            None,
2281            crate::FfiBackend::BoltFfi,
2282        )
2283        .unwrap();
2284
2285        let boltffi_main = fs::read_to_string(&main_activity_path).unwrap();
2286        assert!(boltffi_main.contains("runBenchmarkJson(specJson = spec.toString())"));
2287        assert!(
2288            !boltffi_main.contains("uniffi."),
2289            "backend changes should regenerate the Android runner:\n{}",
2290            boltffi_main
2291        );
2292
2293        fs::remove_dir_all(&temp_dir).ok();
2294    }
2295
2296    #[test]
2297    fn test_ensure_android_project_refreshes_existing_backend_scaffolding() {
2298        let temp_dir = env::temp_dir().join("mobench-sdk-android-refresh-test");
2299        let _ = fs::remove_dir_all(&temp_dir);
2300        fs::create_dir_all(&temp_dir).unwrap();
2301
2302        generate_android_project_with_backend(
2303            &temp_dir,
2304            "refresh_benchmark",
2305            "refresh_benchmark::bench_prove",
2306            crate::FfiBackend::BoltFfi,
2307        )
2308        .unwrap();
2309
2310        let main_activity_path =
2311            temp_dir.join("android/app/src/main/java/dev/world/refreshbenchmark/MainActivity.kt");
2312        let stale_main = fs::read_to_string(&main_activity_path)
2313            .unwrap()
2314            .replace("BENCH_JSON_START", "STALE_BENCH_JSON_START");
2315        fs::write(&main_activity_path, stale_main).unwrap();
2316
2317        ensure_android_project_with_backend_options(
2318            &temp_dir,
2319            "refresh_benchmark",
2320            None,
2321            None,
2322            crate::FfiBackend::BoltFfi,
2323        )
2324        .unwrap();
2325
2326        let refreshed_main = fs::read_to_string(&main_activity_path).unwrap();
2327        assert!(refreshed_main.contains("BENCH_JSON_START"));
2328        assert!(
2329            !refreshed_main.contains("STALE_BENCH_JSON_START"),
2330            "existing same-backend scaffolding should be refreshed:\n{}",
2331            refreshed_main
2332        );
2333
2334        fs::remove_dir_all(&temp_dir).ok();
2335    }
2336
2337    #[test]
2338    fn test_write_boltffi_config_can_target_explicit_output_dir() {
2339        let temp_dir = env::temp_dir().join("mobench-sdk-boltffi-config-output-test");
2340        let _ = fs::remove_dir_all(&temp_dir);
2341        fs::create_dir_all(&temp_dir).unwrap();
2342        let output_dir = temp_dir.join("custom-mobench-output");
2343
2344        write_boltffi_config_with_output_dir(
2345            &temp_dir,
2346            "bolt_benchmark",
2347            "bolt-benchmark",
2348            "BenchRunner",
2349            "com.mobench.boltbenchmark",
2350            &["arm64".to_string(), "x86_64".to_string()],
2351            &output_dir,
2352        )
2353        .unwrap();
2354
2355        let boltffi_toml = fs::read_to_string(temp_dir.join("boltffi.toml")).unwrap();
2356        assert!(boltffi_toml.contains(&format!(
2357            "output = \"{}\"",
2358            output_dir
2359                .join("android/app/src/main/java")
2360                .to_string_lossy()
2361        )));
2362        assert!(boltffi_toml.contains(&format!(
2363            "output = \"{}\"",
2364            output_dir
2365                .join("ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated")
2366                .to_string_lossy()
2367        )));
2368
2369        fs::remove_dir_all(&temp_dir).ok();
2370    }
2371
2372    #[test]
2373    fn test_is_template_file() {
2374        assert!(is_template_file(Path::new("settings.gradle")));
2375        assert!(is_template_file(Path::new("app/build.gradle")));
2376        assert!(is_template_file(Path::new("AndroidManifest.xml")));
2377        assert!(is_template_file(Path::new("strings.xml")));
2378        assert!(is_template_file(Path::new("MainActivity.kt.template")));
2379        assert!(is_template_file(Path::new("project.yml")));
2380        assert!(is_template_file(Path::new("Info.plist")));
2381        assert!(!is_template_file(Path::new("libfoo.so")));
2382        assert!(!is_template_file(Path::new("image.png")));
2383    }
2384
2385    #[test]
2386    fn test_mobile_templates_read_process_peak_memory_compatibly() {
2387        let android =
2388            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
2389        assert!(
2390            !android.contains("sample.processPeakMemoryKb"),
2391            "Android template should not require generated bindings to expose processPeakMemoryKb"
2392        );
2393        assert!(
2394            !android.contains("it.processPeakMemoryKb"),
2395            "Android template should not require generated bindings to expose processPeakMemoryKb"
2396        );
2397        assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
2398        assert!(
2399            !android.contains("sample.cpuTimeMs"),
2400            "Android template should tolerate BenchSample without cpuTimeMs"
2401        );
2402        assert!(
2403            !android.contains("sample.peakMemoryKb"),
2404            "Android template should tolerate BenchSample without peakMemoryKb"
2405        );
2406        assert!(
2407            !android.contains("report.phases"),
2408            "Android template should tolerate BenchReport without phases"
2409        );
2410        assert!(android.contains("ProcessMemorySampler"));
2411        assert!(android.contains("sampleIntervalMs: Long = 1000L"));
2412        assert!(android.contains("/proc/self/smaps_rollup"));
2413        assert!(android.contains("class BenchmarkWorkerService : Service()"));
2414        assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
2415        assert!(android.contains("startForegroundService(intent)"));
2416        assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
2417        assert!(android.contains("fun isBenchmarkComplete()"));
2418        assert!(!android.contains("resultLatch.await"));
2419        assert!(android.contains("memory_process\", \"isolated_worker\""));
2420
2421        let android_test = include_str!(
2422            "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
2423        );
2424        assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
2425        assert!(android_test.contains("Thread.sleep(pollMs)"));
2426        assert!(
2427            android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_HEARTBEAT_INTERVAL_SECS}})")
2428        );
2429        assert!(
2430            android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_BENCHMARK_TIMEOUT_SECS}})")
2431        );
2432        assert!(android_test.contains("activity.isBenchmarkComplete()"));
2433
2434        let ios_test = include_str!(
2435            "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
2436        );
2437        assert!(
2438            ios_test.contains("\\\"error\\\""),
2439            "iOS XCUITest template should fail when the benchmark report is an error payload"
2440        );
2441
2442        let android_manifest =
2443            include_str!("../templates/android/app/src/main/AndroidManifest.xml");
2444        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
2445        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
2446        assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
2447        assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
2448        assert!(android_manifest.contains("android:process=\":mobench_worker\""));
2449
2450        let android_build_gradle = include_str!("../templates/android/app/build.gradle");
2451        assert!(android_build_gradle.contains("generatedMainBenchSpec"));
2452        assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
2453
2454        let ios =
2455            include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
2456        assert!(
2457            !ios.contains("sample.processPeakMemoryKb"),
2458            "iOS template should not require generated bindings to expose processPeakMemoryKb"
2459        );
2460        assert!(
2461            !ios.contains(r"\.processPeakMemoryKb"),
2462            "iOS template should not require generated bindings to expose processPeakMemoryKb"
2463        );
2464        assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
2465        assert!(ios.contains("return [\n                \"name\": name,"));
2466        assert!(
2467            !ios.contains("sample.cpuTimeMs"),
2468            "iOS template should tolerate BenchSample without cpuTimeMs"
2469        );
2470        assert!(
2471            !ios.contains("sample.peakMemoryKb"),
2472            "iOS template should tolerate BenchSample without peakMemoryKb"
2473        );
2474        assert!(
2475            !ios.contains("report.phases"),
2476            "iOS template should tolerate BenchReport without phases"
2477        );
2478        assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
2479        assert!(ios.contains("ProcessMemorySampler"));
2480        assert!(ios.contains("currentProcessResidentMemoryKb"));
2481        assert!(ios.contains("task_info("));
2482        assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
2483        assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
2484        assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
2485    }
2486
2487    #[test]
2488    fn test_android_worker_result_codes_do_not_collide_with_activity_constants() {
2489        let generated_runner =
2490            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
2491        let native_runner = include_str!("native_templates/android/MainActivity.kt.template");
2492
2493        for (runner, success_receiver) in [
2494            (generated_runner, "MobenchResultCode.SUCCESS ->"),
2495            (native_runner, "resultCode == MobenchResultCode.SUCCESS"),
2496        ] {
2497            assert!(
2498                runner.contains("private object MobenchResultCode"),
2499                "worker result codes must live behind a qualified namespace"
2500            );
2501            assert!(runner.contains(success_receiver));
2502            assert!(runner.contains(
2503                "if (result.errorMessage == null) MobenchResultCode.SUCCESS else MobenchResultCode.ERROR"
2504            ));
2505            assert!(
2506                !runner.contains("private const val RESULT_OK"),
2507                "Activity.RESULT_OK shadows an unqualified top-level RESULT_OK"
2508            );
2509        }
2510        assert!(generated_runner.contains("MobenchResultCode.HEARTBEAT ->"));
2511        assert!(generated_runner.contains("receiver?.send(MobenchResultCode.HEARTBEAT"));
2512    }
2513
2514    #[test]
2515    fn test_android_runners_emit_collectable_chunked_benchmark_reports() {
2516        let generated_runner =
2517            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
2518        let native_runner = include_str!("native_templates/android/MainActivity.kt.template");
2519        let boltffi_runner = include_str!("boltffi_templates/android/MainActivity.kt.template");
2520
2521        for runner in [generated_runner, native_runner, boltffi_runner] {
2522            assert!(runner.contains("BENCH_JSON_START"));
2523            assert!(runner.contains("BENCH_JSON_CHUNK"));
2524            assert!(runner.contains("BENCH_JSON_END"));
2525            assert!(runner.contains("val chunkSize = 1000"));
2526            assert!(runner.contains("Character.isHighSurrogate"));
2527        }
2528    }
2529
2530    #[test]
2531    fn generated_android_test_uses_configured_timeout_and_heartbeat() {
2532        let temp_dir = env::temp_dir().join("mobench-sdk-android-timeout-test");
2533        let _ = fs::remove_dir_all(&temp_dir);
2534        fs::create_dir_all(&temp_dir).expect("create temp dir");
2535
2536        unsafe {
2537            std::env::set_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS", "7200");
2538            std::env::set_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS", "15");
2539        }
2540        let result = generate_android_project_with_backend(
2541            &temp_dir,
2542            "my-bench-project",
2543            "sample_fns::fibonacci",
2544            crate::FfiBackend::NativeCAbi,
2545        );
2546        unsafe {
2547            std::env::remove_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS");
2548            std::env::remove_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS");
2549        }
2550        result.expect("generate Android project");
2551
2552        let android_test =
2553            fs::read_to_string(temp_dir.join(
2554                "android/app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt",
2555            ))
2556            .expect("read MainActivityTest.kt");
2557
2558        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(7200)"));
2559        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(15)"));
2560        let _ = fs::remove_dir_all(&temp_dir);
2561    }
2562
2563    #[test]
2564    fn test_validate_no_unreplaced_placeholders() {
2565        // Should pass with no placeholders
2566        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
2567
2568        // Should pass with Gradle variables (not our placeholders)
2569        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
2570
2571        // Should fail with unreplaced template placeholders
2572        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
2573        assert!(result.is_err());
2574        let err = result.unwrap_err().to_string();
2575        assert!(err.contains("{{NAME}}"));
2576    }
2577
2578    #[test]
2579    fn test_to_pascal_case() {
2580        assert_eq!(to_pascal_case("my-project"), "MyProject");
2581        assert_eq!(to_pascal_case("my_project"), "MyProject");
2582        assert_eq!(to_pascal_case("myproject"), "Myproject");
2583        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
2584    }
2585
2586    #[test]
2587    fn test_detect_default_function_finds_benchmark() {
2588        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
2589        let _ = fs::remove_dir_all(&temp_dir);
2590        fs::create_dir_all(temp_dir.join("src")).unwrap();
2591
2592        // Create a lib.rs with a benchmark function
2593        let lib_content = r#"
2594use mobench_sdk::benchmark;
2595
2596/// Some docs
2597#[benchmark]
2598fn my_benchmark_func() {
2599    // benchmark code
2600}
2601
2602fn helper_func() {}
2603"#;
2604        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
2605        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2606
2607        let result = detect_default_function(&temp_dir, "my_crate");
2608        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
2609
2610        // Cleanup
2611        fs::remove_dir_all(&temp_dir).ok();
2612    }
2613
2614    #[test]
2615    fn test_detect_default_function_no_benchmark() {
2616        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
2617        let _ = fs::remove_dir_all(&temp_dir);
2618        fs::create_dir_all(temp_dir.join("src")).unwrap();
2619
2620        // Create a lib.rs without benchmark functions
2621        let lib_content = r#"
2622fn regular_function() {
2623    // no benchmark here
2624}
2625"#;
2626        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
2627
2628        let result = detect_default_function(&temp_dir, "my_crate");
2629        assert!(result.is_none());
2630
2631        // Cleanup
2632        fs::remove_dir_all(&temp_dir).ok();
2633    }
2634
2635    #[test]
2636    fn test_detect_default_function_pub_fn() {
2637        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
2638        let _ = fs::remove_dir_all(&temp_dir);
2639        fs::create_dir_all(temp_dir.join("src")).unwrap();
2640
2641        // Create a lib.rs with a public benchmark function
2642        let lib_content = r#"
2643#[benchmark]
2644pub fn public_bench() {
2645    // benchmark code
2646}
2647"#;
2648        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
2649
2650        let result = detect_default_function(&temp_dir, "test-crate");
2651        assert_eq!(result, Some("test_crate::public_bench".to_string()));
2652
2653        // Cleanup
2654        fs::remove_dir_all(&temp_dir).ok();
2655    }
2656
2657    #[test]
2658    fn test_resolve_default_function_fallback() {
2659        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
2660        let _ = fs::remove_dir_all(&temp_dir);
2661        fs::create_dir_all(&temp_dir).unwrap();
2662
2663        // No lib.rs exists, should fall back to default
2664        let result = resolve_default_function(&temp_dir, "my-crate", None);
2665        assert_eq!(result, "my_crate::example_benchmark");
2666
2667        // Cleanup
2668        fs::remove_dir_all(&temp_dir).ok();
2669    }
2670
2671    #[test]
2672    fn test_sanitize_bundle_id_component() {
2673        // Hyphens should be removed
2674        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
2675        // Underscores should be removed
2676        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
2677        // Mixed separators should all be removed
2678        assert_eq!(
2679            sanitize_bundle_id_component("my-project_name"),
2680            "myprojectname"
2681        );
2682        // Already valid should remain unchanged (but lowercase)
2683        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
2684        // Numbers should be preserved
2685        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
2686        // Uppercase should be lowercased
2687        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
2688        // Complex case
2689        assert_eq!(
2690            sanitize_bundle_id_component("My-Complex_Project-123"),
2691            "mycomplexproject123"
2692        );
2693    }
2694
2695    #[test]
2696    fn test_generate_ios_project_bundle_id_not_duplicated() {
2697        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
2698        // Clean up any previous test run
2699        let _ = fs::remove_dir_all(&temp_dir);
2700        fs::create_dir_all(&temp_dir).unwrap();
2701
2702        // Use a crate name that would previously cause duplication
2703        let crate_name = "bench-mobile";
2704        let bundle_prefix = "dev.world.benchmobile";
2705        let project_pascal = "BenchRunner";
2706
2707        let result = generate_ios_project(
2708            &temp_dir,
2709            crate_name,
2710            project_pascal,
2711            bundle_prefix,
2712            "bench_mobile::test_func",
2713        );
2714        assert!(
2715            result.is_ok(),
2716            "generate_ios_project failed: {:?}",
2717            result.err()
2718        );
2719
2720        // Verify project.yml was created
2721        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
2722        assert!(project_yml_path.exists(), "project.yml should exist");
2723
2724        // Read and verify the bundle ID is correct (not duplicated)
2725        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
2726
2727        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
2728        // NOT "dev.world.benchmobile.benchmobile"
2729        assert!(
2730            project_yml.contains("dev.world.benchmobile.BenchRunner"),
2731            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
2732            project_yml
2733        );
2734        assert!(
2735            !project_yml.contains("dev.world.benchmobile.benchmobile"),
2736            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
2737            project_yml
2738        );
2739        assert!(
2740            project_yml.contains("embed: false"),
2741            "Static xcframework dependency should be link-only, got:\n{}",
2742            project_yml
2743        );
2744
2745        // Cleanup
2746        fs::remove_dir_all(&temp_dir).ok();
2747    }
2748
2749    #[test]
2750    fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
2751        let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
2752        let _ = fs::remove_dir_all(&temp_dir);
2753        fs::create_dir_all(&temp_dir).unwrap();
2754
2755        generate_ios_project(
2756            &temp_dir,
2757            "bench_mobile",
2758            "BenchRunner",
2759            "dev.world.benchmobile",
2760            "bench_mobile::bench_prepare",
2761        )
2762        .unwrap();
2763
2764        let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
2765        fs::create_dir_all(resources_dir.join("nested")).unwrap();
2766        fs::write(
2767            resources_dir.join("bench_spec.json"),
2768            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
2769        )
2770        .unwrap();
2771        fs::write(
2772            resources_dir.join("bench_meta.json"),
2773            r#"{"build_id":"build-123"}"#,
2774        )
2775        .unwrap();
2776        fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
2777
2778        generate_ios_project(
2779            &temp_dir,
2780            "bench_mobile",
2781            "BenchRunner",
2782            "dev.world.benchmobile",
2783            "bench_mobile::bench_prepare",
2784        )
2785        .unwrap();
2786
2787        assert_eq!(
2788            fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
2789            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
2790        );
2791        assert_eq!(
2792            fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
2793            r#"{"build_id":"build-123"}"#
2794        );
2795        assert_eq!(
2796            fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
2797            "keep me"
2798        );
2799
2800        fs::remove_dir_all(&temp_dir).ok();
2801    }
2802
2803    #[test]
2804    fn test_generate_ios_native_backend_runner_template() {
2805        let temp_dir = env::temp_dir().join("mobench-sdk-ios-native-test");
2806        let _ = fs::remove_dir_all(&temp_dir);
2807        fs::create_dir_all(&temp_dir).unwrap();
2808
2809        generate_ios_project_with_backend(
2810            &temp_dir,
2811            "native_benchmark",
2812            "BenchRunner",
2813            "dev.world.nativebenchmark",
2814            "native_benchmark::bench_prove",
2815            crate::FfiBackend::NativeCAbi,
2816        )
2817        .unwrap();
2818
2819        let ffi =
2820            fs::read_to_string(temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift"))
2821                .unwrap();
2822        assert!(ffi.contains("mobench_run_benchmark_json"));
2823        assert!(ffi.contains("mobench_free_buf"));
2824        assert!(ffi.contains("BENCH_FUNCTION"));
2825        assert!(
2826            !ffi.contains("runBenchmark(spec:"),
2827            "native iOS runner must call the JSON C ABI, not UniFFI runBenchmark"
2828        );
2829        assert!(
2830            !ffi.contains("let report: BenchReport"),
2831            "native iOS runner should operate on JSON, not UniFFI BenchReport"
2832        );
2833
2834        let header = fs::read_to_string(
2835            temp_dir.join("ios/BenchRunner/BenchRunner/Generated/native_benchmarkFFI.h"),
2836        )
2837        .unwrap();
2838        assert!(header.contains("mobench_run_benchmark_json"));
2839        let bridging_header = fs::read_to_string(
2840            temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h"),
2841        )
2842        .unwrap();
2843        assert!(bridging_header.contains("#import \"native_benchmarkFFI.h\""));
2844
2845        fs::remove_dir_all(&temp_dir).ok();
2846    }
2847
2848    #[test]
2849    fn test_generate_ios_boltffi_backend_runner_template() {
2850        let temp_dir = env::temp_dir().join("mobench-sdk-ios-boltffi-test");
2851        let _ = fs::remove_dir_all(&temp_dir);
2852        fs::create_dir_all(&temp_dir).unwrap();
2853
2854        generate_ios_project_with_backend(
2855            &temp_dir,
2856            "bolt_benchmark",
2857            "BenchRunner",
2858            "dev.world.boltbenchmark",
2859            "bolt_benchmark::bench_prove",
2860            crate::FfiBackend::BoltFfi,
2861        )
2862        .unwrap();
2863
2864        let ffi =
2865            fs::read_to_string(temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift"))
2866                .unwrap();
2867        assert!(ffi.contains("runBenchmarkJson(specJson:"));
2868        assert!(ffi.contains("BENCH_FUNCTION"));
2869        assert!(
2870            !ffi.contains("runBenchmark(spec:"),
2871            "BoltFFI iOS runner must call BoltFFI JSON bindings, not UniFFI runBenchmark"
2872        );
2873        assert!(
2874            !ffi.contains("mobench_run_benchmark_json"),
2875            "BoltFFI iOS runner must not call the native C ABI bridge"
2876        );
2877        let bridging_header = fs::read_to_string(
2878            temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h"),
2879        )
2880        .unwrap();
2881        assert!(
2882            !bridging_header.contains("#import"),
2883            "BoltFFI iOS runner should import the generated C module from Swift, not a UniFFI-style bridging header"
2884        );
2885
2886        let boltffi_toml = fs::read_to_string(temp_dir.join("boltffi.toml")).unwrap();
2887        assert!(boltffi_toml.contains("module_name = \"BenchRunner\""));
2888        assert!(boltffi_toml.contains("ffi_module_name = \"bolt_benchmarkFFI\""));
2889        assert!(boltffi_toml.contains(
2890            "output = \"target/mobench/ios/BenchRunner/BenchRunner/Generated/BoltFFIGenerated\""
2891        ));
2892
2893        fs::remove_dir_all(&temp_dir).ok();
2894    }
2895
2896    #[test]
2897    fn test_ensure_ios_project_refreshes_existing_content_view_template() {
2898        let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
2899        let _ = fs::remove_dir_all(&temp_dir);
2900        fs::create_dir_all(&temp_dir).unwrap();
2901
2902        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2903            .expect("initial iOS project generation should succeed");
2904
2905        let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
2906        assert!(content_view_path.exists(), "ContentView.swift should exist");
2907
2908        fs::write(&content_view_path, "stale generated content").unwrap();
2909
2910        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2911            .expect("refreshing existing iOS project should succeed");
2912
2913        let refreshed = fs::read_to_string(&content_view_path).unwrap();
2914        assert!(
2915            refreshed.contains("ProfileLaunchOptions"),
2916            "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
2917            refreshed
2918        );
2919        assert!(
2920            refreshed.contains("repeatUntilMs"),
2921            "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
2922            refreshed
2923        );
2924        assert!(
2925            refreshed.contains("Task.detached(priority: .userInitiated)"),
2926            "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
2927            refreshed
2928        );
2929        assert!(
2930            refreshed.contains("await MainActor.run"),
2931            "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
2932            refreshed
2933        );
2934
2935        fs::remove_dir_all(&temp_dir).ok();
2936    }
2937
2938    #[test]
2939    fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
2940        let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
2941        let _ = fs::remove_dir_all(&temp_dir);
2942        fs::create_dir_all(&temp_dir).unwrap();
2943
2944        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2945            .expect("initial iOS project generation should succeed");
2946
2947        let ui_test_path =
2948            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2949        assert!(
2950            ui_test_path.exists(),
2951            "BenchRunnerUITests.swift should exist"
2952        );
2953
2954        fs::write(&ui_test_path, "stale generated content").unwrap();
2955
2956        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2957            .expect("refreshing existing iOS project should succeed");
2958
2959        let refreshed = fs::read_to_string(&ui_test_path).unwrap();
2960        assert!(
2961            refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
2962            "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
2963            refreshed
2964        );
2965        assert!(
2966            refreshed.contains(
2967                "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
2968            ),
2969            "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
2970            refreshed
2971        );
2972
2973        fs::remove_dir_all(&temp_dir).ok();
2974    }
2975
2976    #[test]
2977    fn test_generate_ios_project_uses_configured_benchmark_timeout() {
2978        let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
2979        let _ = fs::remove_dir_all(&temp_dir);
2980        fs::create_dir_all(&temp_dir).unwrap();
2981
2982        let result = generate_ios_project_with_timeout(
2983            &temp_dir,
2984            "sample_fns",
2985            "BenchRunner",
2986            "dev.world.samplefns",
2987            "sample_fns::example_benchmark",
2988            1200,
2989            crate::FfiBackend::Uniffi,
2990        );
2991
2992        assert!(result.is_ok(), "generate_ios_project should succeed");
2993
2994        let ui_test_path =
2995            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2996        let contents = fs::read_to_string(&ui_test_path).unwrap();
2997        assert!(
2998            contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
2999            "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
3000            contents
3001        );
3002
3003        fs::remove_dir_all(&temp_dir).ok();
3004    }
3005
3006    #[test]
3007    fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
3008        assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
3009        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
3010        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
3011        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
3012    }
3013
3014    #[test]
3015    fn test_cross_platform_naming_consistency() {
3016        // Test that Android and iOS use the same naming convention for package/bundle IDs
3017        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
3018        let _ = fs::remove_dir_all(&temp_dir);
3019        fs::create_dir_all(&temp_dir).unwrap();
3020
3021        let project_name = "bench-mobile";
3022
3023        // Generate Android project
3024        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
3025        assert!(
3026            result.is_ok(),
3027            "generate_android_project failed: {:?}",
3028            result.err()
3029        );
3030
3031        // Generate iOS project (mimicking how ensure_ios_project does it)
3032        let bundle_id_component = sanitize_bundle_id_component(project_name);
3033        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
3034        let result = generate_ios_project(
3035            &temp_dir,
3036            &project_name.replace('-', "_"),
3037            "BenchRunner",
3038            &bundle_prefix,
3039            "bench_mobile::test_func",
3040        );
3041        assert!(
3042            result.is_ok(),
3043            "generate_ios_project failed: {:?}",
3044            result.err()
3045        );
3046
3047        // Read Android build.gradle to extract package name
3048        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
3049            .expect("Failed to read Android build.gradle");
3050
3051        // Read iOS project.yml to extract bundle ID prefix
3052        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
3053            .expect("Failed to read iOS project.yml");
3054
3055        // Both should use "benchmobile" (without hyphens or underscores)
3056        // Android: namespace = "dev.world.benchmobile"
3057        // iOS: bundleIdPrefix: dev.world.benchmobile
3058        assert!(
3059            android_build_gradle.contains("dev.world.benchmobile"),
3060            "Android package should be 'dev.world.benchmobile', got:\n{}",
3061            android_build_gradle
3062        );
3063        assert!(
3064            ios_project_yml.contains("dev.world.benchmobile"),
3065            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
3066            ios_project_yml
3067        );
3068
3069        // Ensure Android doesn't use hyphens or underscores in the package ID component
3070        assert!(
3071            !android_build_gradle.contains("dev.world.bench-mobile"),
3072            "Android package should NOT contain hyphens"
3073        );
3074        assert!(
3075            !android_build_gradle.contains("dev.world.bench_mobile"),
3076            "Android package should NOT contain underscores"
3077        );
3078
3079        // Cleanup
3080        fs::remove_dir_all(&temp_dir).ok();
3081    }
3082
3083    #[test]
3084    fn test_cross_platform_version_consistency() {
3085        // Test that Android and iOS use the same version strings
3086        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
3087        let _ = fs::remove_dir_all(&temp_dir);
3088        fs::create_dir_all(&temp_dir).unwrap();
3089
3090        let project_name = "test-project";
3091
3092        // Generate Android project
3093        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
3094        assert!(
3095            result.is_ok(),
3096            "generate_android_project failed: {:?}",
3097            result.err()
3098        );
3099
3100        // Generate iOS project
3101        let bundle_id_component = sanitize_bundle_id_component(project_name);
3102        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
3103        let result = generate_ios_project(
3104            &temp_dir,
3105            &project_name.replace('-', "_"),
3106            "BenchRunner",
3107            &bundle_prefix,
3108            "test_project::test_func",
3109        );
3110        assert!(
3111            result.is_ok(),
3112            "generate_ios_project failed: {:?}",
3113            result.err()
3114        );
3115
3116        // Read Android build.gradle
3117        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
3118            .expect("Failed to read Android build.gradle");
3119
3120        // Read iOS project.yml
3121        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
3122            .expect("Failed to read iOS project.yml");
3123
3124        // Both should use version "1.0.0"
3125        assert!(
3126            android_build_gradle.contains("versionName \"1.0.0\""),
3127            "Android versionName should be '1.0.0', got:\n{}",
3128            android_build_gradle
3129        );
3130        assert!(
3131            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
3132            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
3133            ios_project_yml
3134        );
3135
3136        // Cleanup
3137        fs::remove_dir_all(&temp_dir).ok();
3138    }
3139
3140    #[test]
3141    fn test_bundle_id_prefix_consistency() {
3142        // Test that the bundle ID prefix format is consistent across platforms
3143        let test_cases = vec![
3144            ("my-project", "dev.world.myproject"),
3145            ("bench_mobile", "dev.world.benchmobile"),
3146            ("TestApp", "dev.world.testapp"),
3147            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
3148            (
3149                "app_with_many_underscores",
3150                "dev.world.appwithmanyunderscores",
3151            ),
3152        ];
3153
3154        for (input, expected_prefix) in test_cases {
3155            let sanitized = sanitize_bundle_id_component(input);
3156            let full_prefix = format!("dev.world.{}", sanitized);
3157            assert_eq!(
3158                full_prefix, expected_prefix,
3159                "For input '{}', expected '{}' but got '{}'",
3160                input, expected_prefix, full_prefix
3161            );
3162        }
3163    }
3164}