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