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