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 DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
20const DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS: u64 = 1800;
21const DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS: u64 = 10;
22
23/// Template variable that can be replaced in template files
24#[derive(Debug, Clone)]
25pub struct TemplateVar {
26    pub name: &'static str,
27    pub value: String,
28}
29
30/// Generates a new mobile benchmark project from templates
31///
32/// Creates the necessary directory structure and files for benchmarking on
33/// mobile platforms. This includes:
34/// - A `bench-mobile/` crate for FFI bindings
35/// - Platform-specific app projects (Android and/or iOS)
36/// - Configuration files
37///
38/// # Arguments
39///
40/// * `config` - Configuration for project initialization
41///
42/// # Returns
43///
44/// * `Ok(PathBuf)` - Path to the generated project root
45/// * `Err(BenchError)` - If generation fails
46pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
47    let output_dir = &config.output_dir;
48    let project_slug = sanitize_package_name(&config.project_name);
49    let project_pascal = to_pascal_case(&project_slug);
50    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
51    let bundle_id_component = sanitize_bundle_id_component(&project_slug);
52    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
53
54    // Create base directories
55    fs::create_dir_all(output_dir)?;
56
57    // Generate bench-mobile FFI wrapper crate
58    generate_bench_mobile_crate(output_dir, &project_slug)?;
59
60    // For full project generation (init), use "example_fibonacci" as the default
61    // since the generated example benchmarks include this function
62    let default_function = "example_fibonacci";
63
64    // Generate platform-specific projects
65    match config.target {
66        Target::Android => {
67            generate_android_project(output_dir, &project_slug, default_function)?;
68        }
69        Target::Ios => {
70            generate_ios_project(
71                output_dir,
72                &project_slug,
73                &project_pascal,
74                &bundle_prefix,
75                default_function,
76            )?;
77        }
78        Target::Both => {
79            generate_android_project(output_dir, &project_slug, default_function)?;
80            generate_ios_project(
81                output_dir,
82                &project_slug,
83                &project_pascal,
84                &bundle_prefix,
85                default_function,
86            )?;
87        }
88    }
89
90    // Generate config file
91    generate_config_file(output_dir, config)?;
92
93    // Generate examples if requested
94    if config.generate_examples {
95        generate_example_benchmarks(output_dir)?;
96    }
97
98    Ok(output_dir.clone())
99}
100
101/// Generates the bench-mobile FFI wrapper crate
102fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
103    let crate_dir = output_dir.join("bench-mobile");
104    fs::create_dir_all(crate_dir.join("src"))?;
105
106    let crate_name = format!("{}-bench-mobile", project_name);
107
108    // Generate Cargo.toml
109    // Note: We configure rustls to use 'ring' instead of 'aws-lc-rs' (default in rustls 0.23+)
110    // because aws-lc-rs doesn't compile for Android NDK targets.
111    let cargo_toml = format!(
112        r#"[package]
113name = "{}"
114version = "0.1.0"
115edition = "2021"
116
117[lib]
118crate-type = ["cdylib", "staticlib", "rlib"]
119
120[dependencies]
121mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
122uniffi = "0.28"
123{} = {{ path = ".." }}
124
125[features]
126default = []
127
128[build-dependencies]
129uniffi = {{ version = "0.28", features = ["build"] }}
130
131# Binary for generating UniFFI bindings (used by mobench build)
132[[bin]]
133name = "uniffi-bindgen"
134path = "src/bin/uniffi-bindgen.rs"
135
136# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
137# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
138# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
139#
140# Add this to your root Cargo.toml:
141# [workspace.dependencies]
142# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
143#
144# Then in each crate that uses rustls:
145# [dependencies]
146# rustls = {{ workspace = true }}
147"#,
148        crate_name, project_name
149    );
150
151    fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
152
153    // Generate src/lib.rs
154    let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
155//!
156//! This crate provides the FFI boundary between Rust benchmarks and mobile
157//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
158
159use uniffi;
160
161// Ensure the user crate is linked so benchmark registrations are pulled in.
162extern crate {{USER_CRATE}} as _bench_user_crate;
163
164// Re-export mobench-sdk types with UniFFI annotations
165#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
166pub struct BenchSpec {
167    pub name: String,
168    pub iterations: u32,
169    pub warmup: u32,
170}
171
172#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
173pub struct BenchSample {
174    pub duration_ns: u64,
175    pub cpu_time_ms: Option<u64>,
176    pub peak_memory_kb: Option<u64>,
177    pub process_peak_memory_kb: Option<u64>,
178}
179
180#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
181pub struct SemanticPhase {
182    pub name: String,
183    pub duration_ns: u64,
184}
185
186#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
187pub struct HarnessTimelineSpan {
188    pub phase: String,
189    pub start_offset_ns: u64,
190    pub end_offset_ns: u64,
191    pub iteration: Option<u32>,
192}
193
194#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
195pub struct BenchReport {
196    pub spec: BenchSpec,
197    pub samples: Vec<BenchSample>,
198    pub phases: Vec<SemanticPhase>,
199    pub timeline: Vec<HarnessTimelineSpan>,
200}
201
202#[derive(Debug, thiserror::Error, uniffi::Error)]
203#[uniffi(flat_error)]
204pub enum BenchError {
205    #[error("iterations must be greater than zero")]
206    InvalidIterations,
207
208    #[error("unknown benchmark function: {name}")]
209    UnknownFunction { name: String },
210
211    #[error("benchmark execution failed: {reason}")]
212    ExecutionFailed { reason: String },
213}
214
215// Convert from mobench-sdk types
216impl From<mobench_sdk::BenchSpec> for BenchSpec {
217    fn from(spec: mobench_sdk::BenchSpec) -> Self {
218        Self {
219            name: spec.name,
220            iterations: spec.iterations,
221            warmup: spec.warmup,
222        }
223    }
224}
225
226impl From<BenchSpec> for mobench_sdk::BenchSpec {
227    fn from(spec: BenchSpec) -> Self {
228        Self {
229            name: spec.name,
230            iterations: spec.iterations,
231            warmup: spec.warmup,
232        }
233    }
234}
235
236impl From<mobench_sdk::BenchSample> for BenchSample {
237    fn from(sample: mobench_sdk::BenchSample) -> Self {
238        Self {
239            duration_ns: sample.duration_ns,
240            cpu_time_ms: sample.cpu_time_ms,
241            peak_memory_kb: sample.peak_memory_kb,
242            process_peak_memory_kb: sample.process_peak_memory_kb,
243        }
244    }
245}
246
247impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
248    fn from(phase: mobench_sdk::SemanticPhase) -> Self {
249        Self {
250            name: phase.name,
251            duration_ns: phase.duration_ns,
252        }
253    }
254}
255
256impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
257    fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
258        Self {
259            phase: span.phase,
260            start_offset_ns: span.start_offset_ns,
261            end_offset_ns: span.end_offset_ns,
262            iteration: span.iteration,
263        }
264    }
265}
266
267impl From<mobench_sdk::RunnerReport> for BenchReport {
268    fn from(report: mobench_sdk::RunnerReport) -> Self {
269        Self {
270            spec: report.spec.into(),
271            samples: report.samples.into_iter().map(Into::into).collect(),
272            phases: report.phases.into_iter().map(Into::into).collect(),
273            timeline: report.timeline.into_iter().map(Into::into).collect(),
274        }
275    }
276}
277
278impl From<mobench_sdk::BenchError> for BenchError {
279    fn from(err: mobench_sdk::BenchError) -> Self {
280        match err {
281            mobench_sdk::BenchError::Runner(runner_err) => {
282                BenchError::ExecutionFailed {
283                    reason: runner_err.to_string(),
284                }
285            }
286            mobench_sdk::BenchError::UnknownFunction(name, _available) => {
287                BenchError::UnknownFunction { name }
288            }
289            _ => BenchError::ExecutionFailed {
290                reason: err.to_string(),
291            },
292        }
293    }
294}
295
296/// Runs a benchmark by name with the given specification
297///
298/// This is the main FFI entry point called from mobile platforms.
299#[uniffi::export]
300pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
301    let sdk_spec: mobench_sdk::BenchSpec = spec.into();
302    let report = mobench_sdk::run_benchmark(sdk_spec)?;
303    Ok(report.into())
304}
305
306// Generate UniFFI scaffolding
307uniffi::setup_scaffolding!();
308"#;
309
310    let lib_rs = render_template(
311        lib_rs_template,
312        &[TemplateVar {
313            name: "USER_CRATE",
314            value: project_name.replace('-', "_"),
315        }],
316    );
317    fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
318
319    // Generate build.rs
320    let build_rs = r#"fn main() {
321    uniffi::generate_scaffolding("src/lib.rs").unwrap();
322}
323"#;
324
325    fs::write(crate_dir.join("build.rs"), build_rs)?;
326
327    // Generate uniffi-bindgen binary (used by mobench build)
328    let bin_dir = crate_dir.join("src/bin");
329    fs::create_dir_all(&bin_dir)?;
330    let uniffi_bindgen_rs = r#"fn main() {
331    uniffi::uniffi_bindgen_main()
332}
333"#;
334    fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
335
336    Ok(())
337}
338
339/// Generates Android project structure from templates
340///
341/// This function can be called standalone to generate just the Android
342/// project scaffolding, useful for auto-generation during build.
343///
344/// # Arguments
345///
346/// * `output_dir` - Directory to write the `android/` project into
347/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
348/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
349pub fn generate_android_project(
350    output_dir: &Path,
351    project_slug: &str,
352    default_function: &str,
353) -> Result<(), BenchError> {
354    generate_android_project_with_backend(
355        output_dir,
356        project_slug,
357        default_function,
358        crate::FfiBackend::Uniffi,
359    )
360}
361
362/// Generates Android project structure from templates for a specific FFI backend.
363pub fn generate_android_project_with_backend(
364    output_dir: &Path,
365    project_slug: &str,
366    default_function: &str,
367    ffi_backend: crate::FfiBackend,
368) -> Result<(), BenchError> {
369    let android_benchmark_timeout_secs = resolve_positive_u64_env(
370        "MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS",
371        DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS,
372    );
373    let android_heartbeat_interval_secs = resolve_positive_u64_env(
374        "MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS",
375        DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
376    );
377    generate_android_project_with_options(
378        output_dir,
379        project_slug,
380        default_function,
381        ffi_backend,
382        android_benchmark_timeout_secs,
383        android_heartbeat_interval_secs,
384    )
385}
386
387fn generate_android_project_with_options(
388    output_dir: &Path,
389    project_slug: &str,
390    default_function: &str,
391    ffi_backend: crate::FfiBackend,
392    android_benchmark_timeout_secs: u64,
393    android_heartbeat_interval_secs: u64,
394) -> Result<(), BenchError> {
395    let target_dir = output_dir.join("android");
396    reset_generated_project_dir(&target_dir)?;
397    let library_name = project_slug.replace('-', "_");
398    let project_pascal = to_pascal_case(project_slug);
399    // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS
400    // This ensures both platforms use the same naming convention: "benchmobile" not "bench-mobile"
401    let package_id_component = sanitize_bundle_id_component(project_slug);
402    let package_name = format!("dev.world.{}", package_id_component);
403    let vars = vec![
404        TemplateVar {
405            name: "PROJECT_NAME",
406            value: project_slug.to_string(),
407        },
408        TemplateVar {
409            name: "PROJECT_NAME_PASCAL",
410            value: project_pascal.clone(),
411        },
412        TemplateVar {
413            name: "APP_NAME",
414            value: format!("{} Benchmark", project_pascal),
415        },
416        TemplateVar {
417            name: "PACKAGE_NAME",
418            value: package_name.clone(),
419        },
420        TemplateVar {
421            name: "UNIFFI_NAMESPACE",
422            value: library_name.clone(),
423        },
424        TemplateVar {
425            name: "LIBRARY_NAME",
426            value: library_name,
427        },
428        TemplateVar {
429            name: "DEFAULT_FUNCTION",
430            value: default_function.to_string(),
431        },
432        TemplateVar {
433            name: "ANDROID_BENCHMARK_TIMEOUT_SECS",
434            value: android_benchmark_timeout_secs.to_string(),
435        },
436        TemplateVar {
437            name: "ANDROID_HEARTBEAT_INTERVAL_SECS",
438            value: android_heartbeat_interval_secs.to_string(),
439        },
440    ];
441    render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
442
443    // Move Kotlin files to the correct package directory structure
444    // The package "dev.world.{project_slug}" maps to directory "dev/world/{project_slug}/"
445    move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
446    if !ffi_backend.uses_uniffi() {
447        write_native_android_main_activity(&target_dir, &package_name, &vars)?;
448    }
449
450    Ok(())
451}
452
453fn write_native_android_main_activity(
454    target_dir: &Path,
455    package_name: &str,
456    vars: &[TemplateVar],
457) -> Result<(), BenchError> {
458    let relative_package = package_name.replace('.', "/");
459    let path = target_dir
460        .join("app/src/main/java")
461        .join(relative_package)
462        .join("MainActivity.kt");
463    let rendered = render_template(NATIVE_ANDROID_MAIN_ACTIVITY_TEMPLATE, vars);
464    validate_no_unreplaced_placeholders(&rendered, Path::new("MainActivity.kt"))?;
465    fs::write(&path, rendered).map_err(BenchError::Io)
466}
467
468fn collect_preserved_files(
469    root: &Path,
470    current: &Path,
471    preserved: &mut Vec<(PathBuf, Vec<u8>)>,
472) -> Result<(), BenchError> {
473    let mut entries = fs::read_dir(current)?
474        .collect::<Result<Vec<_>, _>>()
475        .map_err(BenchError::Io)?;
476    entries.sort_by_key(|entry| entry.path());
477
478    for entry in entries {
479        let path = entry.path();
480        if path.is_dir() {
481            collect_preserved_files(root, &path, preserved)?;
482            continue;
483        }
484
485        let relative = path.strip_prefix(root).map_err(|e| {
486            BenchError::Build(format!(
487                "Failed to preserve generated resource {:?}: {}",
488                path, e
489            ))
490        })?;
491        preserved.push((relative.to_path_buf(), fs::read(&path)?));
492    }
493
494    Ok(())
495}
496
497fn collect_preserved_ios_resources(
498    target_dir: &Path,
499) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
500    let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
501    let mut preserved = Vec::new();
502
503    if resources_dir.exists() {
504        collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
505    }
506
507    Ok(preserved)
508}
509
510fn restore_preserved_ios_resources(
511    target_dir: &Path,
512    preserved_resources: &[(PathBuf, Vec<u8>)],
513) -> Result<(), BenchError> {
514    if preserved_resources.is_empty() {
515        return Ok(());
516    }
517
518    let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
519    for (relative, contents) in preserved_resources {
520        let resource_path = resources_dir.join(relative);
521        if let Some(parent) = resource_path.parent() {
522            fs::create_dir_all(parent)?;
523        }
524        fs::write(resource_path, contents)?;
525    }
526
527    Ok(())
528}
529
530fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
531    if target_dir.exists() {
532        fs::remove_dir_all(target_dir).map_err(|e| {
533            BenchError::Build(format!(
534                "Failed to clear existing generated project at {:?}: {}",
535                target_dir, e
536            ))
537        })?;
538    }
539    Ok(())
540}
541
542/// Moves Kotlin source files to the correct package directory structure
543///
544/// Android requires source files to be in directories matching their package declaration.
545/// For example, a file with `package dev.world.my_project` must be in
546/// `app/src/main/java/dev/world/my_project/`.
547///
548/// This function moves:
549/// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/`
550/// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/`
551fn move_kotlin_files_to_package_dir(
552    android_dir: &Path,
553    package_name: &str,
554) -> Result<(), BenchError> {
555    // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project")
556    let package_path = package_name.replace('.', "/");
557
558    // Move main source files
559    let main_java_dir = android_dir.join("app/src/main/java");
560    let main_package_dir = main_java_dir.join(&package_path);
561    move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
562
563    // Move test source files
564    let test_java_dir = android_dir.join("app/src/androidTest/java");
565    let test_package_dir = test_java_dir.join(&package_path);
566    move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
567
568    Ok(())
569}
570
571/// Moves a single Kotlin file from source directory to package directory
572fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
573    let src_file = src_dir.join(filename);
574    if !src_file.exists() {
575        // File doesn't exist in source, nothing to move
576        return Ok(());
577    }
578
579    // Create the package directory if it doesn't exist
580    fs::create_dir_all(dest_dir).map_err(|e| {
581        BenchError::Build(format!(
582            "Failed to create package directory {:?}: {}",
583            dest_dir, e
584        ))
585    })?;
586
587    let dest_file = dest_dir.join(filename);
588
589    // Move the file (copy + delete for cross-filesystem compatibility)
590    fs::copy(&src_file, &dest_file).map_err(|e| {
591        BenchError::Build(format!(
592            "Failed to copy {} to {:?}: {}",
593            filename, dest_file, e
594        ))
595    })?;
596
597    fs::remove_file(&src_file).map_err(|e| {
598        BenchError::Build(format!(
599            "Failed to remove original file {:?}: {}",
600            src_file, e
601        ))
602    })?;
603
604    Ok(())
605}
606
607/// Generates iOS project structure from templates
608///
609/// This function can be called standalone to generate just the iOS
610/// project scaffolding, useful for auto-generation during build.
611///
612/// # Arguments
613///
614/// * `output_dir` - Directory to write the `ios/` project into
615/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
616/// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile")
617/// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench")
618/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
619pub fn generate_ios_project(
620    output_dir: &Path,
621    project_slug: &str,
622    project_pascal: &str,
623    bundle_prefix: &str,
624    default_function: &str,
625) -> Result<(), BenchError> {
626    generate_ios_project_with_backend(
627        output_dir,
628        project_slug,
629        project_pascal,
630        bundle_prefix,
631        default_function,
632        crate::FfiBackend::Uniffi,
633    )
634}
635
636/// Generates iOS project structure from templates for a specific FFI backend.
637pub fn generate_ios_project_with_backend(
638    output_dir: &Path,
639    project_slug: &str,
640    project_pascal: &str,
641    bundle_prefix: &str,
642    default_function: &str,
643    ffi_backend: crate::FfiBackend,
644) -> Result<(), BenchError> {
645    let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
646        std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
647            .ok()
648            .as_deref(),
649    );
650    generate_ios_project_with_timeout(
651        output_dir,
652        project_slug,
653        project_pascal,
654        bundle_prefix,
655        default_function,
656        ios_benchmark_timeout_secs,
657        ffi_backend,
658    )
659}
660
661fn generate_ios_project_with_timeout(
662    output_dir: &Path,
663    project_slug: &str,
664    project_pascal: &str,
665    bundle_prefix: &str,
666    default_function: &str,
667    ios_benchmark_timeout_secs: u64,
668    ffi_backend: crate::FfiBackend,
669) -> Result<(), BenchError> {
670    let target_dir = output_dir.join("ios");
671    let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
672    reset_generated_project_dir(&target_dir)?;
673    // Sanitize bundle ID components to ensure they only contain alphanumeric characters
674    // iOS bundle identifiers should not contain hyphens or underscores
675    let sanitized_bundle_prefix = {
676        let parts: Vec<&str> = bundle_prefix.split('.').collect();
677        parts
678            .iter()
679            .map(|part| sanitize_bundle_id_component(part))
680            .collect::<Vec<_>>()
681            .join(".")
682    };
683    // Use the actual app name (project_pascal, e.g., "BenchRunner") for the bundle ID suffix,
684    // not the crate name again. This prevents duplication like "dev.world.benchmobile.benchmobile"
685    // and produces the correct "dev.world.benchmobile.BenchRunner"
686    let vars = vec![
687        TemplateVar {
688            name: "DEFAULT_FUNCTION",
689            value: default_function.to_string(),
690        },
691        TemplateVar {
692            name: "PROJECT_NAME_PASCAL",
693            value: project_pascal.to_string(),
694        },
695        TemplateVar {
696            name: "BUNDLE_ID_PREFIX",
697            value: sanitized_bundle_prefix.clone(),
698        },
699        TemplateVar {
700            name: "BUNDLE_ID",
701            value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
702        },
703        TemplateVar {
704            name: "LIBRARY_NAME",
705            value: project_slug.replace('-', "_"),
706        },
707        TemplateVar {
708            name: "IOS_BENCHMARK_TIMEOUT_SECS",
709            value: ios_benchmark_timeout_secs.to_string(),
710        },
711    ];
712    render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
713    if !ffi_backend.uses_uniffi() {
714        write_native_ios_bench_runner_ffi(&target_dir, project_pascal, &vars)?;
715    }
716    restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
717    Ok(())
718}
719
720fn write_native_ios_bench_runner_ffi(
721    target_dir: &Path,
722    project_pascal: &str,
723    vars: &[TemplateVar],
724) -> Result<(), BenchError> {
725    let app_dir = target_dir.join("BenchRunner").join(project_pascal);
726    let path = app_dir.join("BenchRunnerFFI.swift");
727    let generated_dir = app_dir.join("Generated");
728    fs::create_dir_all(&generated_dir).map_err(BenchError::Io)?;
729    let library_name = vars
730        .iter()
731        .find(|var| var.name == "LIBRARY_NAME")
732        .map(|var| var.value.as_str())
733        .unwrap_or("bench_mobile");
734    let header_path = generated_dir.join(format!("{library_name}FFI.h"));
735    fs::write(&header_path, native_c_abi_header(library_name)).map_err(BenchError::Io)?;
736
737    let rendered = render_template(NATIVE_IOS_BENCH_RUNNER_FFI_TEMPLATE, vars);
738    validate_no_unreplaced_placeholders(&rendered, Path::new("BenchRunnerFFI.swift"))?;
739    fs::write(&path, rendered).map_err(BenchError::Io)
740}
741
742fn native_c_abi_header(framework_name: &str) -> String {
743    let guard = format!(
744        "{}_MOBENCH_NATIVE_C_ABI_H",
745        framework_name.to_ascii_uppercase()
746    )
747    .replace('-', "_");
748    format!(
749        r#"#ifndef {guard}
750#define {guard}
751
752#include <stdint.h>
753#include <stddef.h>
754
755#ifdef __cplusplus
756extern "C" {{
757#endif
758
759typedef struct MobenchBuf {{
760    uint8_t *ptr;
761    uintptr_t len;
762    uintptr_t cap;
763}} MobenchBuf;
764
765int32_t mobench_run_benchmark_json(const uint8_t *spec_ptr, uintptr_t spec_len, MobenchBuf *out);
766void mobench_free_buf(MobenchBuf *buf);
767const char *mobench_last_error_message(void);
768
769#ifdef __cplusplus
770}}
771#endif
772
773#endif
774"#
775    )
776}
777
778fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
779    value
780        .and_then(|raw| raw.parse::<u64>().ok())
781        .filter(|secs| *secs > 0)
782        .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
783}
784
785fn resolve_positive_u64_env(name: &str, default: u64) -> u64 {
786    std::env::var(name)
787        .ok()
788        .and_then(|raw| raw.parse::<u64>().ok())
789        .filter(|secs| *secs > 0)
790        .unwrap_or(default)
791}
792
793/// Generates bench-config.toml configuration file
794fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
795    let config_target = match config.target {
796        Target::Ios => "ios",
797        Target::Android | Target::Both => "android",
798    };
799    let config_content = format!(
800        r#"# mobench configuration
801# This file controls how benchmarks are executed on devices.
802
803target = "{}"
804function = "example_fibonacci"
805iterations = 100
806warmup = 10
807device_matrix = "device-matrix.yaml"
808device_tags = ["default"]
809
810[browserstack]
811app_automate_username = "${{BROWSERSTACK_USERNAME}}"
812app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
813project = "{}-benchmarks"
814
815[ios_xcuitest]
816app = "target/ios/BenchRunner.ipa"
817test_suite = "target/ios/BenchRunnerUITests.zip"
818"#,
819        config_target, config.project_name
820    );
821
822    fs::write(output_dir.join("bench-config.toml"), config_content)?;
823
824    Ok(())
825}
826
827/// Generates example benchmark functions
828fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
829    let examples_dir = output_dir.join("benches");
830    fs::create_dir_all(&examples_dir)?;
831
832    let example_content = r#"//! Example benchmarks
833//!
834//! This file demonstrates how to write benchmarks with mobench-sdk.
835
836use mobench_sdk::benchmark;
837
838/// Simple benchmark example
839#[benchmark]
840fn example_fibonacci() {
841    let result = fibonacci(30);
842    std::hint::black_box(result);
843}
844
845/// Another example with a loop
846#[benchmark]
847fn example_sum() {
848    let mut sum = 0u64;
849    for i in 0..10000 {
850        sum = sum.wrapping_add(i);
851    }
852    std::hint::black_box(sum);
853}
854
855// Helper function (not benchmarked)
856fn fibonacci(n: u32) -> u64 {
857    match n {
858        0 => 0,
859        1 => 1,
860        _ => {
861            let mut a = 0u64;
862            let mut b = 1u64;
863            for _ in 2..=n {
864                let next = a.wrapping_add(b);
865                a = b;
866                b = next;
867            }
868            b
869        }
870    }
871}
872"#;
873
874    fs::write(examples_dir.join("example.rs"), example_content)?;
875
876    Ok(())
877}
878
879/// File extensions that should be processed for template variable substitution
880const TEMPLATE_EXTENSIONS: &[&str] = &[
881    "gradle",
882    "xml",
883    "kt",
884    "java",
885    "swift",
886    "yml",
887    "yaml",
888    "json",
889    "toml",
890    "md",
891    "txt",
892    "h",
893    "m",
894    "plist",
895    "pbxproj",
896    "xcscheme",
897    "xcworkspacedata",
898    "entitlements",
899    "modulemap",
900];
901
902fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
903    for entry in dir.entries() {
904        match entry {
905            DirEntry::Dir(sub) => {
906                // Skip cache directories
907                if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
908                    continue;
909                }
910                render_dir(sub, out_root, vars)?;
911            }
912            DirEntry::File(file) => {
913                if file.path().components().any(|c| c.as_os_str() == ".gradle") {
914                    continue;
915                }
916                // file.path() returns the full relative path from the embedded dir root
917                let mut relative = file.path().to_path_buf();
918                let mut contents = file.contents().to_vec();
919
920                // Check if file has .template extension (explicit template)
921                let is_explicit_template = relative
922                    .extension()
923                    .map(|ext| ext == "template")
924                    .unwrap_or(false);
925
926                // Check if file is a text file that should be processed for templates
927                let should_render = is_explicit_template || is_template_file(&relative);
928
929                if is_explicit_template {
930                    // Remove .template extension from output filename
931                    relative.set_extension("");
932                }
933
934                if should_render && let Ok(text) = std::str::from_utf8(&contents) {
935                    let rendered = render_template(text, vars);
936                    // Validate that all template variables were replaced
937                    validate_no_unreplaced_placeholders(&rendered, &relative)?;
938                    contents = rendered.into_bytes();
939                }
940
941                let out_path = out_root.join(relative);
942                if let Some(parent) = out_path.parent() {
943                    fs::create_dir_all(parent)?;
944                }
945                fs::write(&out_path, contents)?;
946            }
947        }
948    }
949    Ok(())
950}
951
952/// Checks if a file should be processed for template variable substitution
953/// based on its extension
954fn is_template_file(path: &Path) -> bool {
955    // Check for .template extension on any file
956    if let Some(ext) = path.extension() {
957        if ext == "template" {
958            return true;
959        }
960        // Check if the base extension is in our list
961        if let Some(ext_str) = ext.to_str() {
962            return TEMPLATE_EXTENSIONS.contains(&ext_str);
963        }
964    }
965    // Also check the filename without the .template extension
966    if let Some(stem) = path.file_stem() {
967        let stem_path = Path::new(stem);
968        if let Some(ext) = stem_path.extension()
969            && let Some(ext_str) = ext.to_str()
970        {
971            return TEMPLATE_EXTENSIONS.contains(&ext_str);
972        }
973    }
974    false
975}
976
977/// Validates that no unreplaced template placeholders remain in the rendered content
978fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
979    // Find all {{...}} patterns
980    let mut pos = 0;
981    let mut unreplaced = Vec::new();
982
983    while let Some(start) = content[pos..].find("{{") {
984        let abs_start = pos + start;
985        if let Some(end) = content[abs_start..].find("}}") {
986            let placeholder = &content[abs_start..abs_start + end + 2];
987            // Extract just the variable name
988            let var_name = &content[abs_start + 2..abs_start + end];
989            // Skip placeholders that look like Gradle variable syntax (e.g., ${...})
990            // or other non-template patterns
991            if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
992                unreplaced.push(placeholder.to_string());
993            }
994            pos = abs_start + end + 2;
995        } else {
996            break;
997        }
998    }
999
1000    if !unreplaced.is_empty() {
1001        return Err(BenchError::Build(format!(
1002            "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
1003             This is a bug in mobench-sdk. Please report it at:\n\
1004             https://github.com/worldcoin/mobile-bench-rs/issues",
1005            file_path, unreplaced
1006        )));
1007    }
1008
1009    Ok(())
1010}
1011
1012fn render_template(input: &str, vars: &[TemplateVar]) -> String {
1013    let mut output = input.to_string();
1014    for var in vars {
1015        output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
1016    }
1017    output
1018}
1019
1020/// Sanitizes a string to be a valid iOS bundle identifier component
1021///
1022/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9),
1023/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency,
1024/// this function converts all non-alphanumeric characters to lowercase letters only.
1025///
1026/// Examples:
1027/// - "bench-mobile" -> "benchmobile"
1028/// - "bench_mobile" -> "benchmobile"
1029/// - "my-project_name" -> "myprojectname"
1030pub fn sanitize_bundle_id_component(name: &str) -> String {
1031    name.chars()
1032        .filter(|c| c.is_ascii_alphanumeric())
1033        .collect::<String>()
1034        .to_lowercase()
1035}
1036
1037fn sanitize_package_name(name: &str) -> String {
1038    name.chars()
1039        .map(|c| {
1040            if c.is_ascii_alphanumeric() {
1041                c.to_ascii_lowercase()
1042            } else {
1043                '-'
1044            }
1045        })
1046        .collect::<String>()
1047        .trim_matches('-')
1048        .replace("--", "-")
1049}
1050
1051/// Converts a string to PascalCase
1052pub fn to_pascal_case(input: &str) -> String {
1053    input
1054        .split(|c: char| !c.is_ascii_alphanumeric())
1055        .filter(|s| !s.is_empty())
1056        .map(|s| {
1057            let mut chars = s.chars();
1058            let first = chars.next().unwrap().to_ascii_uppercase();
1059            let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
1060            format!("{}{}", first, rest)
1061        })
1062        .collect::<String>()
1063}
1064
1065/// Checks if the Android project scaffolding exists at the given output directory
1066///
1067/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists.
1068pub fn android_project_exists(output_dir: &Path) -> bool {
1069    let android_dir = output_dir.join("android");
1070    android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
1071}
1072
1073/// Checks if the iOS project scaffolding exists at the given output directory
1074///
1075/// Returns true if the `ios/BenchRunner/project.yml` file exists.
1076pub fn ios_project_exists(output_dir: &Path) -> bool {
1077    output_dir.join("ios/BenchRunner/project.yml").exists()
1078}
1079
1080/// Checks whether an existing iOS project was generated for the given library name.
1081///
1082/// Returns `false` if the xcframework reference in `project.yml` doesn't match,
1083/// which means the project needs to be regenerated for the new crate.
1084fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1085    let project_yml = output_dir.join("ios/BenchRunner/project.yml");
1086    let Ok(content) = std::fs::read_to_string(&project_yml) else {
1087        return false;
1088    };
1089    let expected = format!("../{}.xcframework", library_name);
1090    content.contains(&expected)
1091}
1092
1093/// Checks whether an existing Android project was generated for the given library name.
1094///
1095/// Returns `false` if the JNI library name in `build.gradle` doesn't match,
1096/// which means the project needs to be regenerated for the new crate.
1097fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1098    let build_gradle = output_dir.join("android/app/build.gradle");
1099    let Ok(content) = std::fs::read_to_string(&build_gradle) else {
1100        return false;
1101    };
1102    let expected = format!("lib{}.so", library_name);
1103    content.contains(&expected)
1104}
1105
1106/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
1107///
1108/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1109/// the first one found in the format `{crate_name}::{function_name}`.
1110///
1111/// # Arguments
1112///
1113/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1114/// * `crate_name` - Name of the crate (used as prefix for the function name)
1115///
1116/// # Returns
1117///
1118/// * `Some(String)` - The detected function name in format `crate_name::function_name`
1119/// * `None` - If no benchmark functions are found or if the file cannot be read
1120pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
1121    let lib_rs = crate_dir.join("src/lib.rs");
1122    if !lib_rs.exists() {
1123        return None;
1124    }
1125
1126    let file = fs::File::open(&lib_rs).ok()?;
1127    let reader = BufReader::new(file);
1128
1129    let mut found_benchmark_attr = false;
1130    let crate_name_normalized = crate_name.replace('-', "_");
1131
1132    for line in reader.lines().map_while(Result::ok) {
1133        let trimmed = line.trim();
1134
1135        // Check for #[benchmark] attribute
1136        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1137            found_benchmark_attr = true;
1138            continue;
1139        }
1140
1141        // If we found a benchmark attribute, look for the function definition
1142        if found_benchmark_attr {
1143            // Look for "fn function_name" or "pub fn function_name"
1144            if let Some(fn_pos) = trimmed.find("fn ") {
1145                let after_fn = &trimmed[fn_pos + 3..];
1146                // Extract function name (until '(' or whitespace)
1147                let fn_name: String = after_fn
1148                    .chars()
1149                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1150                    .collect();
1151
1152                if !fn_name.is_empty() {
1153                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
1154                }
1155            }
1156            // Reset if we hit a line that's not a function definition
1157            // (could be another attribute or comment)
1158            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1159                found_benchmark_attr = false;
1160            }
1161        }
1162    }
1163
1164    None
1165}
1166
1167/// Detects all benchmark functions in a crate by scanning src/lib.rs for `#[benchmark]`
1168///
1169/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1170/// all found in the format `{crate_name}::{function_name}`.
1171///
1172/// # Arguments
1173///
1174/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1175/// * `crate_name` - Name of the crate (used as prefix for the function names)
1176///
1177/// # Returns
1178///
1179/// A vector of benchmark function names in format `crate_name::function_name`
1180pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1181    let lib_rs = crate_dir.join("src/lib.rs");
1182    if !lib_rs.exists() {
1183        return Vec::new();
1184    }
1185
1186    let Ok(file) = fs::File::open(&lib_rs) else {
1187        return Vec::new();
1188    };
1189    let reader = BufReader::new(file);
1190
1191    let mut benchmarks = Vec::new();
1192    let mut found_benchmark_attr = false;
1193    let crate_name_normalized = crate_name.replace('-', "_");
1194
1195    for line in reader.lines().map_while(Result::ok) {
1196        let trimmed = line.trim();
1197
1198        // Check for #[benchmark] attribute
1199        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1200            found_benchmark_attr = true;
1201            continue;
1202        }
1203
1204        // If we found a benchmark attribute, look for the function definition
1205        if found_benchmark_attr {
1206            // Look for "fn function_name" or "pub fn function_name"
1207            if let Some(fn_pos) = trimmed.find("fn ") {
1208                let after_fn = &trimmed[fn_pos + 3..];
1209                // Extract function name (until '(' or whitespace)
1210                let fn_name: String = after_fn
1211                    .chars()
1212                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1213                    .collect();
1214
1215                if !fn_name.is_empty() {
1216                    benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1217                }
1218                found_benchmark_attr = false;
1219            }
1220            // Reset if we hit a line that's not a function definition
1221            // (could be another attribute or comment)
1222            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1223                found_benchmark_attr = false;
1224            }
1225        }
1226    }
1227
1228    benchmarks
1229}
1230
1231/// Validates that a benchmark function exists in the crate source
1232///
1233/// # Arguments
1234///
1235/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1236/// * `crate_name` - Name of the crate (used as prefix for the function names)
1237/// * `function_name` - The function name to validate (with or without crate prefix)
1238///
1239/// # Returns
1240///
1241/// `true` if the function is found, `false` otherwise
1242pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1243    let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1244    let crate_name_normalized = crate_name.replace('-', "_");
1245
1246    // Normalize the function name - add crate prefix if missing
1247    let normalized_name = if function_name.contains("::") {
1248        function_name.to_string()
1249    } else {
1250        format!("{}::{}", crate_name_normalized, function_name)
1251    };
1252
1253    benchmarks.iter().any(|b| b == &normalized_name)
1254}
1255
1256/// Resolves the default benchmark function for a project
1257///
1258/// This function attempts to auto-detect benchmark functions from the crate's source.
1259/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
1260///
1261/// # Arguments
1262///
1263/// * `project_root` - Root directory of the project
1264/// * `crate_name` - Name of the benchmark crate
1265/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
1266///
1267/// # Returns
1268///
1269/// The default function name in format `crate_name::function_name`
1270pub fn resolve_default_function(
1271    project_root: &Path,
1272    crate_name: &str,
1273    crate_dir: Option<&Path>,
1274) -> String {
1275    let crate_name_normalized = crate_name.replace('-', "_");
1276
1277    // Try to find the crate directory
1278    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1279        vec![dir.to_path_buf()]
1280    } else {
1281        vec![
1282            project_root.join("bench-mobile"),
1283            project_root.join("crates").join(crate_name),
1284            project_root.to_path_buf(),
1285        ]
1286    };
1287
1288    // Try to detect benchmarks from each potential location
1289    for dir in &search_dirs {
1290        if dir.join("Cargo.toml").exists()
1291            && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1292        {
1293            return detected;
1294        }
1295    }
1296
1297    // Fallback: use a sensible default based on crate name
1298    format!("{}::example_benchmark", crate_name_normalized)
1299}
1300
1301/// Auto-generates Android project scaffolding from a crate name
1302///
1303/// This is a convenience function that derives template variables from the
1304/// crate name and generates the Android project structure. It auto-detects
1305/// the default benchmark function from the crate's source code.
1306///
1307/// # Arguments
1308///
1309/// * `output_dir` - Directory to write the `android/` project into
1310/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1311pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1312    ensure_android_project_with_options(output_dir, crate_name, None, None)
1313}
1314
1315/// Auto-generates Android project scaffolding with additional options
1316///
1317/// This is a more flexible version of `ensure_android_project` that allows
1318/// specifying a custom default function and/or crate directory.
1319///
1320/// # Arguments
1321///
1322/// * `output_dir` - Directory to write the `android/` project into
1323/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1324/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1325/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1326pub fn ensure_android_project_with_options(
1327    output_dir: &Path,
1328    crate_name: &str,
1329    project_root: Option<&Path>,
1330    crate_dir: Option<&Path>,
1331) -> Result<(), BenchError> {
1332    ensure_android_project_with_backend_options(
1333        output_dir,
1334        crate_name,
1335        project_root,
1336        crate_dir,
1337        crate::FfiBackend::Uniffi,
1338    )
1339}
1340
1341/// Auto-generates Android project scaffolding for the selected FFI backend.
1342pub fn ensure_android_project_with_backend_options(
1343    output_dir: &Path,
1344    crate_name: &str,
1345    project_root: Option<&Path>,
1346    crate_dir: Option<&Path>,
1347    ffi_backend: crate::FfiBackend,
1348) -> Result<(), BenchError> {
1349    let library_name = crate_name.replace('-', "_");
1350    if android_project_exists(output_dir)
1351        && android_project_matches_library(output_dir, &library_name)
1352    {
1353        return Ok(());
1354    }
1355
1356    println!(
1357        "Android project not found, generating scaffolding for {} backend...",
1358        ffi_backend
1359    );
1360    let project_slug = crate_name.replace('-', "_");
1361
1362    // Resolve the default function by auto-detecting from source
1363    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1364    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1365
1366    generate_android_project_with_backend(
1367        output_dir,
1368        &project_slug,
1369        &default_function,
1370        ffi_backend,
1371    )?;
1372    println!(
1373        "  Generated Android project at {:?}",
1374        output_dir.join("android")
1375    );
1376    println!("  Default benchmark function: {}", default_function);
1377    Ok(())
1378}
1379
1380/// Auto-generates iOS project scaffolding from a crate name
1381///
1382/// This is a convenience function that derives template variables from the
1383/// crate name and generates the iOS project structure. It auto-detects
1384/// the default benchmark function from the crate's source code.
1385///
1386/// # Arguments
1387///
1388/// * `output_dir` - Directory to write the `ios/` project into
1389/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1390pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1391    ensure_ios_project_with_options(output_dir, crate_name, None, None)
1392}
1393
1394/// Auto-generates iOS project scaffolding with additional options
1395///
1396/// This is a more flexible version of `ensure_ios_project` that allows
1397/// specifying a custom default function and/or crate directory.
1398///
1399/// # Arguments
1400///
1401/// * `output_dir` - Directory to write the `ios/` project into
1402/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1403/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1404/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1405pub fn ensure_ios_project_with_options(
1406    output_dir: &Path,
1407    crate_name: &str,
1408    project_root: Option<&Path>,
1409    crate_dir: Option<&Path>,
1410) -> Result<(), BenchError> {
1411    ensure_ios_project_with_backend_options(
1412        output_dir,
1413        crate_name,
1414        project_root,
1415        crate_dir,
1416        crate::FfiBackend::Uniffi,
1417    )
1418}
1419
1420/// Auto-generates iOS project scaffolding for the selected FFI backend.
1421pub fn ensure_ios_project_with_backend_options(
1422    output_dir: &Path,
1423    crate_name: &str,
1424    project_root: Option<&Path>,
1425    crate_dir: Option<&Path>,
1426    ffi_backend: crate::FfiBackend,
1427) -> Result<(), BenchError> {
1428    let library_name = crate_name.replace('-', "_");
1429    let project_exists = ios_project_exists(output_dir);
1430    let project_matches = ios_project_matches_library(output_dir, &library_name);
1431    if project_exists && !project_matches {
1432        println!(
1433            "Existing iOS scaffolding does not match library, regenerating for {} backend...",
1434            ffi_backend
1435        );
1436    } else if project_exists {
1437        println!(
1438            "Refreshing generated iOS scaffolding for {} backend...",
1439            ffi_backend
1440        );
1441    } else {
1442        println!(
1443            "iOS project not found, generating scaffolding for {} backend...",
1444            ffi_backend
1445        );
1446    }
1447
1448    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
1449    let project_pascal = "BenchRunner";
1450    // Derive library name and bundle prefix from crate name
1451    let library_name = crate_name.replace('-', "_");
1452    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
1453    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
1454    let bundle_id_component = sanitize_bundle_id_component(crate_name);
1455    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1456
1457    // Resolve the default function by auto-detecting from source
1458    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1459    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1460
1461    generate_ios_project_with_backend(
1462        output_dir,
1463        &library_name,
1464        project_pascal,
1465        &bundle_prefix,
1466        &default_function,
1467        ffi_backend,
1468    )?;
1469    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
1470    println!("  Default benchmark function: {}", default_function);
1471    Ok(())
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477    use std::env;
1478
1479    #[test]
1480    fn test_generate_bench_mobile_crate() {
1481        let temp_dir = env::temp_dir().join("mobench-sdk-test");
1482        fs::create_dir_all(&temp_dir).unwrap();
1483
1484        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1485        assert!(result.is_ok());
1486
1487        // Verify files were created
1488        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1489        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1490        assert!(temp_dir.join("bench-mobile/build.rs").exists());
1491        let cargo_toml =
1492            fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
1493        assert!(
1494            cargo_toml.contains(
1495                r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
1496            ),
1497            "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
1498        );
1499
1500        // Cleanup
1501        fs::remove_dir_all(&temp_dir).ok();
1502    }
1503
1504    #[test]
1505    fn test_generate_android_project_no_unreplaced_placeholders() {
1506        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1507        // Clean up any previous test run
1508        let _ = fs::remove_dir_all(&temp_dir);
1509        fs::create_dir_all(&temp_dir).unwrap();
1510
1511        let result =
1512            generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1513        assert!(
1514            result.is_ok(),
1515            "generate_android_project failed: {:?}",
1516            result.err()
1517        );
1518
1519        // Verify key files exist
1520        let android_dir = temp_dir.join("android");
1521        assert!(android_dir.join("settings.gradle").exists());
1522        assert!(android_dir.join("app/build.gradle").exists());
1523        assert!(
1524            android_dir
1525                .join("app/src/main/AndroidManifest.xml")
1526                .exists()
1527        );
1528        assert!(
1529            android_dir
1530                .join("app/src/main/res/values/strings.xml")
1531                .exists()
1532        );
1533        assert!(
1534            android_dir
1535                .join("app/src/main/res/values/themes.xml")
1536                .exists()
1537        );
1538
1539        // Verify no unreplaced placeholders remain in generated files
1540        let files_to_check = [
1541            "settings.gradle",
1542            "app/build.gradle",
1543            "app/src/main/AndroidManifest.xml",
1544            "app/src/main/res/values/strings.xml",
1545            "app/src/main/res/values/themes.xml",
1546        ];
1547
1548        for file in files_to_check {
1549            let path = android_dir.join(file);
1550            let contents =
1551                fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1552
1553            // Check for unreplaced placeholders
1554            let has_placeholder = contents.contains("{{") && contents.contains("}}");
1555            assert!(
1556                !has_placeholder,
1557                "File {} contains unreplaced template placeholders: {}",
1558                file, contents
1559            );
1560        }
1561
1562        // Verify specific substitutions were made
1563        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1564        assert!(
1565            settings.contains("my-bench-project-android")
1566                || settings.contains("my_bench_project-android"),
1567            "settings.gradle should contain project name"
1568        );
1569
1570        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1571        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
1572        assert!(
1573            build_gradle.contains("dev.world.mybenchproject"),
1574            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1575        );
1576        assert!(
1577            !build_gradle.contains("testBuildType \"release\""),
1578            "debug builds should be able to produce assembleDebugAndroidTest"
1579        );
1580        assert!(
1581            build_gradle.contains("mobenchTestBuildType"),
1582            "release builds should be able to request assembleReleaseAndroidTest"
1583        );
1584
1585        let manifest =
1586            fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1587        assert!(
1588            manifest.contains("Theme.MyBenchProject"),
1589            "AndroidManifest.xml should contain PascalCase theme name"
1590        );
1591
1592        let strings =
1593            fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1594        assert!(
1595            strings.contains("Benchmark"),
1596            "strings.xml should contain app name with Benchmark"
1597        );
1598
1599        // Verify Kotlin files are in the correct package directory structure
1600        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
1601        let main_activity_path =
1602            android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1603        assert!(
1604            main_activity_path.exists(),
1605            "MainActivity.kt should be in package directory: {:?}",
1606            main_activity_path
1607        );
1608
1609        let test_activity_path = android_dir
1610            .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1611        assert!(
1612            test_activity_path.exists(),
1613            "MainActivityTest.kt should be in package directory: {:?}",
1614            test_activity_path
1615        );
1616
1617        // Verify the files are NOT in the root java directory
1618        assert!(
1619            !android_dir
1620                .join("app/src/main/java/MainActivity.kt")
1621                .exists(),
1622            "MainActivity.kt should not be in root java directory"
1623        );
1624        assert!(
1625            !android_dir
1626                .join("app/src/androidTest/java/MainActivityTest.kt")
1627                .exists(),
1628            "MainActivityTest.kt should not be in root java directory"
1629        );
1630
1631        // Cleanup
1632        fs::remove_dir_all(&temp_dir).ok();
1633    }
1634
1635    #[test]
1636    fn test_generate_android_project_replaces_previous_package_tree() {
1637        let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1638        let _ = fs::remove_dir_all(&temp_dir);
1639        fs::create_dir_all(&temp_dir).unwrap();
1640
1641        generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1642            .unwrap();
1643        let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1644        assert!(
1645            old_package_dir.exists(),
1646            "expected first package tree to exist"
1647        );
1648
1649        generate_android_project(
1650            &temp_dir,
1651            "basic_benchmark",
1652            "basic_benchmark::bench_fibonacci",
1653        )
1654        .unwrap();
1655
1656        let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1657        assert!(
1658            new_package_dir.exists(),
1659            "expected new package tree to exist"
1660        );
1661        assert!(
1662            !old_package_dir.exists(),
1663            "old package tree should be removed when regenerating the Android scaffold"
1664        );
1665
1666        fs::remove_dir_all(&temp_dir).ok();
1667    }
1668
1669    #[test]
1670    fn test_generate_android_native_backend_runner_template() {
1671        let temp_dir = env::temp_dir().join("mobench-sdk-android-native-test");
1672        let _ = fs::remove_dir_all(&temp_dir);
1673        fs::create_dir_all(&temp_dir).unwrap();
1674
1675        generate_android_project_with_backend(
1676            &temp_dir,
1677            "native_benchmark",
1678            "native_benchmark::bench_prove",
1679            crate::FfiBackend::NativeCAbi,
1680        )
1681        .unwrap();
1682
1683        let main_activity = fs::read_to_string(
1684            temp_dir.join("android/app/src/main/java/dev/world/nativebenchmark/MainActivity.kt"),
1685        )
1686        .unwrap();
1687        assert!(main_activity.contains("com.sun.jna.Native"));
1688        assert!(main_activity.contains("mobench_run_benchmark_json"));
1689        assert!(main_activity.contains("BENCH_JSON"));
1690        assert!(main_activity.contains("bench_spec.json"));
1691        assert!(
1692            !main_activity.contains("uniffi."),
1693            "native Android runner must not import UniFFI bindings:\n{}",
1694            main_activity
1695        );
1696        assert!(
1697            !main_activity.contains("runBenchmark("),
1698            "native Android runner must call the JSON C ABI, not UniFFI runBenchmark"
1699        );
1700
1701        fs::remove_dir_all(&temp_dir).ok();
1702    }
1703
1704    #[test]
1705    fn test_is_template_file() {
1706        assert!(is_template_file(Path::new("settings.gradle")));
1707        assert!(is_template_file(Path::new("app/build.gradle")));
1708        assert!(is_template_file(Path::new("AndroidManifest.xml")));
1709        assert!(is_template_file(Path::new("strings.xml")));
1710        assert!(is_template_file(Path::new("MainActivity.kt.template")));
1711        assert!(is_template_file(Path::new("project.yml")));
1712        assert!(is_template_file(Path::new("Info.plist")));
1713        assert!(!is_template_file(Path::new("libfoo.so")));
1714        assert!(!is_template_file(Path::new("image.png")));
1715    }
1716
1717    #[test]
1718    fn test_mobile_templates_read_process_peak_memory_compatibly() {
1719        let android =
1720            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1721        assert!(
1722            !android.contains("sample.processPeakMemoryKb"),
1723            "Android template should not require generated bindings to expose processPeakMemoryKb"
1724        );
1725        assert!(
1726            !android.contains("it.processPeakMemoryKb"),
1727            "Android template should not require generated bindings to expose processPeakMemoryKb"
1728        );
1729        assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1730        assert!(
1731            !android.contains("sample.cpuTimeMs"),
1732            "Android template should tolerate BenchSample without cpuTimeMs"
1733        );
1734        assert!(
1735            !android.contains("sample.peakMemoryKb"),
1736            "Android template should tolerate BenchSample without peakMemoryKb"
1737        );
1738        assert!(
1739            !android.contains("report.phases"),
1740            "Android template should tolerate BenchReport without phases"
1741        );
1742        assert!(android.contains("ProcessMemorySampler"));
1743        assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1744        assert!(android.contains("/proc/self/smaps_rollup"));
1745        assert!(android.contains("class BenchmarkWorkerService : Service()"));
1746        assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1747        assert!(android.contains("startForegroundService(intent)"));
1748        assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1749        assert!(android.contains("fun isBenchmarkComplete()"));
1750        assert!(!android.contains("resultLatch.await"));
1751        assert!(android.contains("memory_process\", \"isolated_worker\""));
1752
1753        let android_test = include_str!(
1754            "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1755        );
1756        assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1757        assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1758        assert!(
1759            android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_HEARTBEAT_INTERVAL_SECS}})")
1760        );
1761        assert!(
1762            android_test.contains("TimeUnit.SECONDS.toMillis({{ANDROID_BENCHMARK_TIMEOUT_SECS}})")
1763        );
1764        assert!(android_test.contains("activity.isBenchmarkComplete()"));
1765
1766        let ios_test = include_str!(
1767            "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1768        );
1769        assert!(
1770            ios_test.contains("\\\"error\\\""),
1771            "iOS XCUITest template should fail when the benchmark report is an error payload"
1772        );
1773
1774        let android_manifest =
1775            include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1776        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1777        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1778        assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1779        assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1780        assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1781
1782        let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1783        assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1784        assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1785
1786        let ios =
1787            include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1788        assert!(
1789            !ios.contains("sample.processPeakMemoryKb"),
1790            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1791        );
1792        assert!(
1793            !ios.contains(r"\.processPeakMemoryKb"),
1794            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1795        );
1796        assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1797        assert!(ios.contains("return [\n                \"name\": name,"));
1798        assert!(
1799            !ios.contains("sample.cpuTimeMs"),
1800            "iOS template should tolerate BenchSample without cpuTimeMs"
1801        );
1802        assert!(
1803            !ios.contains("sample.peakMemoryKb"),
1804            "iOS template should tolerate BenchSample without peakMemoryKb"
1805        );
1806        assert!(
1807            !ios.contains("report.phases"),
1808            "iOS template should tolerate BenchReport without phases"
1809        );
1810        assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1811        assert!(ios.contains("ProcessMemorySampler"));
1812        assert!(ios.contains("currentProcessResidentMemoryKb"));
1813        assert!(ios.contains("task_info("));
1814        assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1815        assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1816        assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1817    }
1818
1819    #[test]
1820    fn generated_android_test_uses_configured_timeout_and_heartbeat() {
1821        let temp_dir = env::temp_dir().join("mobench-sdk-android-timeout-test");
1822        let _ = fs::remove_dir_all(&temp_dir);
1823        fs::create_dir_all(&temp_dir).expect("create temp dir");
1824
1825        unsafe {
1826            std::env::set_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS", "7200");
1827            std::env::set_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS", "15");
1828        }
1829        let result = generate_android_project_with_backend(
1830            &temp_dir,
1831            "my-bench-project",
1832            "sample_fns::fibonacci",
1833            crate::FfiBackend::NativeCAbi,
1834        );
1835        unsafe {
1836            std::env::remove_var("MOBENCH_ANDROID_BENCHMARK_TIMEOUT_SECS");
1837            std::env::remove_var("MOBENCH_ANDROID_HEARTBEAT_INTERVAL_SECS");
1838        }
1839        result.expect("generate Android project");
1840
1841        let android_test =
1842            fs::read_to_string(temp_dir.join(
1843                "android/app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt",
1844            ))
1845            .expect("read MainActivityTest.kt");
1846
1847        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(7200)"));
1848        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(15)"));
1849        let _ = fs::remove_dir_all(&temp_dir);
1850    }
1851
1852    #[test]
1853    fn test_validate_no_unreplaced_placeholders() {
1854        // Should pass with no placeholders
1855        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1856
1857        // Should pass with Gradle variables (not our placeholders)
1858        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1859
1860        // Should fail with unreplaced template placeholders
1861        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1862        assert!(result.is_err());
1863        let err = result.unwrap_err().to_string();
1864        assert!(err.contains("{{NAME}}"));
1865    }
1866
1867    #[test]
1868    fn test_to_pascal_case() {
1869        assert_eq!(to_pascal_case("my-project"), "MyProject");
1870        assert_eq!(to_pascal_case("my_project"), "MyProject");
1871        assert_eq!(to_pascal_case("myproject"), "Myproject");
1872        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1873    }
1874
1875    #[test]
1876    fn test_detect_default_function_finds_benchmark() {
1877        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1878        let _ = fs::remove_dir_all(&temp_dir);
1879        fs::create_dir_all(temp_dir.join("src")).unwrap();
1880
1881        // Create a lib.rs with a benchmark function
1882        let lib_content = r#"
1883use mobench_sdk::benchmark;
1884
1885/// Some docs
1886#[benchmark]
1887fn my_benchmark_func() {
1888    // benchmark code
1889}
1890
1891fn helper_func() {}
1892"#;
1893        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1894        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1895
1896        let result = detect_default_function(&temp_dir, "my_crate");
1897        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1898
1899        // Cleanup
1900        fs::remove_dir_all(&temp_dir).ok();
1901    }
1902
1903    #[test]
1904    fn test_detect_default_function_no_benchmark() {
1905        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1906        let _ = fs::remove_dir_all(&temp_dir);
1907        fs::create_dir_all(temp_dir.join("src")).unwrap();
1908
1909        // Create a lib.rs without benchmark functions
1910        let lib_content = r#"
1911fn regular_function() {
1912    // no benchmark here
1913}
1914"#;
1915        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1916
1917        let result = detect_default_function(&temp_dir, "my_crate");
1918        assert!(result.is_none());
1919
1920        // Cleanup
1921        fs::remove_dir_all(&temp_dir).ok();
1922    }
1923
1924    #[test]
1925    fn test_detect_default_function_pub_fn() {
1926        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1927        let _ = fs::remove_dir_all(&temp_dir);
1928        fs::create_dir_all(temp_dir.join("src")).unwrap();
1929
1930        // Create a lib.rs with a public benchmark function
1931        let lib_content = r#"
1932#[benchmark]
1933pub fn public_bench() {
1934    // benchmark code
1935}
1936"#;
1937        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1938
1939        let result = detect_default_function(&temp_dir, "test-crate");
1940        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1941
1942        // Cleanup
1943        fs::remove_dir_all(&temp_dir).ok();
1944    }
1945
1946    #[test]
1947    fn test_resolve_default_function_fallback() {
1948        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1949        let _ = fs::remove_dir_all(&temp_dir);
1950        fs::create_dir_all(&temp_dir).unwrap();
1951
1952        // No lib.rs exists, should fall back to default
1953        let result = resolve_default_function(&temp_dir, "my-crate", None);
1954        assert_eq!(result, "my_crate::example_benchmark");
1955
1956        // Cleanup
1957        fs::remove_dir_all(&temp_dir).ok();
1958    }
1959
1960    #[test]
1961    fn test_sanitize_bundle_id_component() {
1962        // Hyphens should be removed
1963        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1964        // Underscores should be removed
1965        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1966        // Mixed separators should all be removed
1967        assert_eq!(
1968            sanitize_bundle_id_component("my-project_name"),
1969            "myprojectname"
1970        );
1971        // Already valid should remain unchanged (but lowercase)
1972        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1973        // Numbers should be preserved
1974        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1975        // Uppercase should be lowercased
1976        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1977        // Complex case
1978        assert_eq!(
1979            sanitize_bundle_id_component("My-Complex_Project-123"),
1980            "mycomplexproject123"
1981        );
1982    }
1983
1984    #[test]
1985    fn test_generate_ios_project_bundle_id_not_duplicated() {
1986        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1987        // Clean up any previous test run
1988        let _ = fs::remove_dir_all(&temp_dir);
1989        fs::create_dir_all(&temp_dir).unwrap();
1990
1991        // Use a crate name that would previously cause duplication
1992        let crate_name = "bench-mobile";
1993        let bundle_prefix = "dev.world.benchmobile";
1994        let project_pascal = "BenchRunner";
1995
1996        let result = generate_ios_project(
1997            &temp_dir,
1998            crate_name,
1999            project_pascal,
2000            bundle_prefix,
2001            "bench_mobile::test_func",
2002        );
2003        assert!(
2004            result.is_ok(),
2005            "generate_ios_project failed: {:?}",
2006            result.err()
2007        );
2008
2009        // Verify project.yml was created
2010        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
2011        assert!(project_yml_path.exists(), "project.yml should exist");
2012
2013        // Read and verify the bundle ID is correct (not duplicated)
2014        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
2015
2016        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
2017        // NOT "dev.world.benchmobile.benchmobile"
2018        assert!(
2019            project_yml.contains("dev.world.benchmobile.BenchRunner"),
2020            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
2021            project_yml
2022        );
2023        assert!(
2024            !project_yml.contains("dev.world.benchmobile.benchmobile"),
2025            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
2026            project_yml
2027        );
2028        assert!(
2029            project_yml.contains("embed: false"),
2030            "Static xcframework dependency should be link-only, got:\n{}",
2031            project_yml
2032        );
2033
2034        // Cleanup
2035        fs::remove_dir_all(&temp_dir).ok();
2036    }
2037
2038    #[test]
2039    fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
2040        let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
2041        let _ = fs::remove_dir_all(&temp_dir);
2042        fs::create_dir_all(&temp_dir).unwrap();
2043
2044        generate_ios_project(
2045            &temp_dir,
2046            "bench_mobile",
2047            "BenchRunner",
2048            "dev.world.benchmobile",
2049            "bench_mobile::bench_prepare",
2050        )
2051        .unwrap();
2052
2053        let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
2054        fs::create_dir_all(resources_dir.join("nested")).unwrap();
2055        fs::write(
2056            resources_dir.join("bench_spec.json"),
2057            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
2058        )
2059        .unwrap();
2060        fs::write(
2061            resources_dir.join("bench_meta.json"),
2062            r#"{"build_id":"build-123"}"#,
2063        )
2064        .unwrap();
2065        fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
2066
2067        generate_ios_project(
2068            &temp_dir,
2069            "bench_mobile",
2070            "BenchRunner",
2071            "dev.world.benchmobile",
2072            "bench_mobile::bench_prepare",
2073        )
2074        .unwrap();
2075
2076        assert_eq!(
2077            fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
2078            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
2079        );
2080        assert_eq!(
2081            fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
2082            r#"{"build_id":"build-123"}"#
2083        );
2084        assert_eq!(
2085            fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
2086            "keep me"
2087        );
2088
2089        fs::remove_dir_all(&temp_dir).ok();
2090    }
2091
2092    #[test]
2093    fn test_generate_ios_native_backend_runner_template() {
2094        let temp_dir = env::temp_dir().join("mobench-sdk-ios-native-test");
2095        let _ = fs::remove_dir_all(&temp_dir);
2096        fs::create_dir_all(&temp_dir).unwrap();
2097
2098        generate_ios_project_with_backend(
2099            &temp_dir,
2100            "native_benchmark",
2101            "BenchRunner",
2102            "dev.world.nativebenchmark",
2103            "native_benchmark::bench_prove",
2104            crate::FfiBackend::NativeCAbi,
2105        )
2106        .unwrap();
2107
2108        let ffi =
2109            fs::read_to_string(temp_dir.join("ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift"))
2110                .unwrap();
2111        assert!(ffi.contains("mobench_run_benchmark_json"));
2112        assert!(ffi.contains("mobench_free_buf"));
2113        assert!(ffi.contains("BENCH_FUNCTION"));
2114        assert!(
2115            !ffi.contains("runBenchmark(spec:"),
2116            "native iOS runner must call the JSON C ABI, not UniFFI runBenchmark"
2117        );
2118        assert!(
2119            !ffi.contains("let report: BenchReport"),
2120            "native iOS runner should operate on JSON, not UniFFI BenchReport"
2121        );
2122
2123        let header = fs::read_to_string(
2124            temp_dir.join("ios/BenchRunner/BenchRunner/Generated/native_benchmarkFFI.h"),
2125        )
2126        .unwrap();
2127        assert!(header.contains("mobench_run_benchmark_json"));
2128
2129        fs::remove_dir_all(&temp_dir).ok();
2130    }
2131
2132    #[test]
2133    fn test_ensure_ios_project_refreshes_existing_content_view_template() {
2134        let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
2135        let _ = fs::remove_dir_all(&temp_dir);
2136        fs::create_dir_all(&temp_dir).unwrap();
2137
2138        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2139            .expect("initial iOS project generation should succeed");
2140
2141        let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
2142        assert!(content_view_path.exists(), "ContentView.swift should exist");
2143
2144        fs::write(&content_view_path, "stale generated content").unwrap();
2145
2146        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2147            .expect("refreshing existing iOS project should succeed");
2148
2149        let refreshed = fs::read_to_string(&content_view_path).unwrap();
2150        assert!(
2151            refreshed.contains("ProfileLaunchOptions"),
2152            "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
2153            refreshed
2154        );
2155        assert!(
2156            refreshed.contains("repeatUntilMs"),
2157            "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
2158            refreshed
2159        );
2160        assert!(
2161            refreshed.contains("Task.detached(priority: .userInitiated)"),
2162            "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
2163            refreshed
2164        );
2165        assert!(
2166            refreshed.contains("await MainActor.run"),
2167            "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
2168            refreshed
2169        );
2170
2171        fs::remove_dir_all(&temp_dir).ok();
2172    }
2173
2174    #[test]
2175    fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
2176        let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
2177        let _ = fs::remove_dir_all(&temp_dir);
2178        fs::create_dir_all(&temp_dir).unwrap();
2179
2180        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2181            .expect("initial iOS project generation should succeed");
2182
2183        let ui_test_path =
2184            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2185        assert!(
2186            ui_test_path.exists(),
2187            "BenchRunnerUITests.swift should exist"
2188        );
2189
2190        fs::write(&ui_test_path, "stale generated content").unwrap();
2191
2192        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2193            .expect("refreshing existing iOS project should succeed");
2194
2195        let refreshed = fs::read_to_string(&ui_test_path).unwrap();
2196        assert!(
2197            refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
2198            "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
2199            refreshed
2200        );
2201        assert!(
2202            refreshed.contains(
2203                "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
2204            ),
2205            "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
2206            refreshed
2207        );
2208
2209        fs::remove_dir_all(&temp_dir).ok();
2210    }
2211
2212    #[test]
2213    fn test_generate_ios_project_uses_configured_benchmark_timeout() {
2214        let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
2215        let _ = fs::remove_dir_all(&temp_dir);
2216        fs::create_dir_all(&temp_dir).unwrap();
2217
2218        let result = generate_ios_project_with_timeout(
2219            &temp_dir,
2220            "sample_fns",
2221            "BenchRunner",
2222            "dev.world.samplefns",
2223            "sample_fns::example_benchmark",
2224            1200,
2225            crate::FfiBackend::Uniffi,
2226        );
2227
2228        assert!(result.is_ok(), "generate_ios_project should succeed");
2229
2230        let ui_test_path =
2231            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2232        let contents = fs::read_to_string(&ui_test_path).unwrap();
2233        assert!(
2234            contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
2235            "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
2236            contents
2237        );
2238
2239        fs::remove_dir_all(&temp_dir).ok();
2240    }
2241
2242    #[test]
2243    fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
2244        assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
2245        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
2246        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
2247        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
2248    }
2249
2250    #[test]
2251    fn test_cross_platform_naming_consistency() {
2252        // Test that Android and iOS use the same naming convention for package/bundle IDs
2253        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
2254        let _ = fs::remove_dir_all(&temp_dir);
2255        fs::create_dir_all(&temp_dir).unwrap();
2256
2257        let project_name = "bench-mobile";
2258
2259        // Generate Android project
2260        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
2261        assert!(
2262            result.is_ok(),
2263            "generate_android_project failed: {:?}",
2264            result.err()
2265        );
2266
2267        // Generate iOS project (mimicking how ensure_ios_project does it)
2268        let bundle_id_component = sanitize_bundle_id_component(project_name);
2269        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2270        let result = generate_ios_project(
2271            &temp_dir,
2272            &project_name.replace('-', "_"),
2273            "BenchRunner",
2274            &bundle_prefix,
2275            "bench_mobile::test_func",
2276        );
2277        assert!(
2278            result.is_ok(),
2279            "generate_ios_project failed: {:?}",
2280            result.err()
2281        );
2282
2283        // Read Android build.gradle to extract package name
2284        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2285            .expect("Failed to read Android build.gradle");
2286
2287        // Read iOS project.yml to extract bundle ID prefix
2288        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2289            .expect("Failed to read iOS project.yml");
2290
2291        // Both should use "benchmobile" (without hyphens or underscores)
2292        // Android: namespace = "dev.world.benchmobile"
2293        // iOS: bundleIdPrefix: dev.world.benchmobile
2294        assert!(
2295            android_build_gradle.contains("dev.world.benchmobile"),
2296            "Android package should be 'dev.world.benchmobile', got:\n{}",
2297            android_build_gradle
2298        );
2299        assert!(
2300            ios_project_yml.contains("dev.world.benchmobile"),
2301            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
2302            ios_project_yml
2303        );
2304
2305        // Ensure Android doesn't use hyphens or underscores in the package ID component
2306        assert!(
2307            !android_build_gradle.contains("dev.world.bench-mobile"),
2308            "Android package should NOT contain hyphens"
2309        );
2310        assert!(
2311            !android_build_gradle.contains("dev.world.bench_mobile"),
2312            "Android package should NOT contain underscores"
2313        );
2314
2315        // Cleanup
2316        fs::remove_dir_all(&temp_dir).ok();
2317    }
2318
2319    #[test]
2320    fn test_cross_platform_version_consistency() {
2321        // Test that Android and iOS use the same version strings
2322        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
2323        let _ = fs::remove_dir_all(&temp_dir);
2324        fs::create_dir_all(&temp_dir).unwrap();
2325
2326        let project_name = "test-project";
2327
2328        // Generate Android project
2329        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
2330        assert!(
2331            result.is_ok(),
2332            "generate_android_project failed: {:?}",
2333            result.err()
2334        );
2335
2336        // Generate iOS project
2337        let bundle_id_component = sanitize_bundle_id_component(project_name);
2338        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2339        let result = generate_ios_project(
2340            &temp_dir,
2341            &project_name.replace('-', "_"),
2342            "BenchRunner",
2343            &bundle_prefix,
2344            "test_project::test_func",
2345        );
2346        assert!(
2347            result.is_ok(),
2348            "generate_ios_project failed: {:?}",
2349            result.err()
2350        );
2351
2352        // Read Android build.gradle
2353        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2354            .expect("Failed to read Android build.gradle");
2355
2356        // Read iOS project.yml
2357        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2358            .expect("Failed to read iOS project.yml");
2359
2360        // Both should use version "1.0.0"
2361        assert!(
2362            android_build_gradle.contains("versionName \"1.0.0\""),
2363            "Android versionName should be '1.0.0', got:\n{}",
2364            android_build_gradle
2365        );
2366        assert!(
2367            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2368            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2369            ios_project_yml
2370        );
2371
2372        // Cleanup
2373        fs::remove_dir_all(&temp_dir).ok();
2374    }
2375
2376    #[test]
2377    fn test_bundle_id_prefix_consistency() {
2378        // Test that the bundle ID prefix format is consistent across platforms
2379        let test_cases = vec![
2380            ("my-project", "dev.world.myproject"),
2381            ("bench_mobile", "dev.world.benchmobile"),
2382            ("TestApp", "dev.world.testapp"),
2383            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2384            (
2385                "app_with_many_underscores",
2386                "dev.world.appwithmanyunderscores",
2387            ),
2388        ];
2389
2390        for (input, expected_prefix) in test_cases {
2391            let sanitized = sanitize_bundle_id_component(input);
2392            let full_prefix = format!("dev.world.{}", sanitized);
2393            assert_eq!(
2394                full_prefix, expected_prefix,
2395                "For input '{}', expected '{}' but got '{}'",
2396                input, expected_prefix, full_prefix
2397            );
2398        }
2399    }
2400}