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");
15
16/// Template variable that can be replaced in template files
17#[derive(Debug, Clone)]
18pub struct TemplateVar {
19    pub name: &'static str,
20    pub value: String,
21}
22
23/// Generates a new mobile benchmark project from templates
24///
25/// Creates the necessary directory structure and files for benchmarking on
26/// mobile platforms. This includes:
27/// - A `bench-mobile/` crate for FFI bindings
28/// - Platform-specific app projects (Android and/or iOS)
29/// - Configuration files
30///
31/// # Arguments
32///
33/// * `config` - Configuration for project initialization
34///
35/// # Returns
36///
37/// * `Ok(PathBuf)` - Path to the generated project root
38/// * `Err(BenchError)` - If generation fails
39pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
40    let output_dir = &config.output_dir;
41    let project_slug = sanitize_package_name(&config.project_name);
42    let project_pascal = to_pascal_case(&project_slug);
43    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
44    let bundle_id_component = sanitize_bundle_id_component(&project_slug);
45    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
46
47    // Create base directories
48    fs::create_dir_all(output_dir)?;
49
50    // Generate bench-mobile FFI wrapper crate
51    generate_bench_mobile_crate(output_dir, &project_slug)?;
52
53    // For full project generation (init), use "example_fibonacci" as the default
54    // since the generated example benchmarks include this function
55    let default_function = "example_fibonacci";
56
57    // Generate platform-specific projects
58    match config.target {
59        Target::Android => {
60            generate_android_project(output_dir, &project_slug, default_function)?;
61        }
62        Target::Ios => {
63            generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?;
64        }
65        Target::Both => {
66            generate_android_project(output_dir, &project_slug, default_function)?;
67            generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?;
68        }
69    }
70
71    // Generate config file
72    generate_config_file(output_dir, config)?;
73
74    // Generate examples if requested
75    if config.generate_examples {
76        generate_example_benchmarks(output_dir)?;
77    }
78
79    Ok(output_dir.clone())
80}
81
82/// Generates the bench-mobile FFI wrapper crate
83fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
84    let crate_dir = output_dir.join("bench-mobile");
85    fs::create_dir_all(crate_dir.join("src"))?;
86
87    let crate_name = format!("{}-bench-mobile", project_name);
88
89    // Generate Cargo.toml
90    // Note: We configure rustls to use 'ring' instead of 'aws-lc-rs' (default in rustls 0.23+)
91    // because aws-lc-rs doesn't compile for Android NDK targets.
92    let cargo_toml = format!(
93        r#"[package]
94name = "{}"
95version = "0.1.0"
96edition = "2021"
97
98[lib]
99crate-type = ["cdylib", "staticlib", "rlib"]
100
101[dependencies]
102mobench-sdk = {{ path = ".." }}
103uniffi = "0.28"
104{} = {{ path = ".." }}
105
106[features]
107default = []
108
109[build-dependencies]
110uniffi = {{ version = "0.28", features = ["build"] }}
111
112# Binary for generating UniFFI bindings (used by mobench build)
113[[bin]]
114name = "uniffi-bindgen"
115path = "src/bin/uniffi-bindgen.rs"
116
117# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
118# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
119# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
120#
121# Add this to your root Cargo.toml:
122# [workspace.dependencies]
123# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
124#
125# Then in each crate that uses rustls:
126# [dependencies]
127# rustls = {{ workspace = true }}
128"#,
129        crate_name, project_name
130    );
131
132    fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
133
134    // Generate src/lib.rs
135    let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
136//!
137//! This crate provides the FFI boundary between Rust benchmarks and mobile
138//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
139
140use uniffi;
141
142// Ensure the user crate is linked so benchmark registrations are pulled in.
143extern crate {{USER_CRATE}} as _bench_user_crate;
144
145// Re-export mobench-sdk types with UniFFI annotations
146#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
147pub struct BenchSpec {
148    pub name: String,
149    pub iterations: u32,
150    pub warmup: u32,
151}
152
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
154pub struct BenchSample {
155    pub duration_ns: u64,
156}
157
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
159pub struct BenchReport {
160    pub spec: BenchSpec,
161    pub samples: Vec<BenchSample>,
162}
163
164#[derive(Debug, thiserror::Error, uniffi::Error)]
165#[uniffi(flat_error)]
166pub enum BenchError {
167    #[error("iterations must be greater than zero")]
168    InvalidIterations,
169
170    #[error("unknown benchmark function: {name}")]
171    UnknownFunction { name: String },
172
173    #[error("benchmark execution failed: {reason}")]
174    ExecutionFailed { reason: String },
175}
176
177// Convert from mobench-sdk types
178impl From<mobench_sdk::BenchSpec> for BenchSpec {
179    fn from(spec: mobench_sdk::BenchSpec) -> Self {
180        Self {
181            name: spec.name,
182            iterations: spec.iterations,
183            warmup: spec.warmup,
184        }
185    }
186}
187
188impl From<BenchSpec> for mobench_sdk::BenchSpec {
189    fn from(spec: BenchSpec) -> Self {
190        Self {
191            name: spec.name,
192            iterations: spec.iterations,
193            warmup: spec.warmup,
194        }
195    }
196}
197
198impl From<mobench_sdk::BenchSample> for BenchSample {
199    fn from(sample: mobench_sdk::BenchSample) -> Self {
200        Self {
201            duration_ns: sample.duration_ns,
202        }
203    }
204}
205
206impl From<mobench_sdk::RunnerReport> for BenchReport {
207    fn from(report: mobench_sdk::RunnerReport) -> Self {
208        Self {
209            spec: report.spec.into(),
210            samples: report.samples.into_iter().map(Into::into).collect(),
211        }
212    }
213}
214
215impl From<mobench_sdk::BenchError> for BenchError {
216    fn from(err: mobench_sdk::BenchError) -> Self {
217        match err {
218            mobench_sdk::BenchError::Runner(runner_err) => {
219                BenchError::ExecutionFailed {
220                    reason: runner_err.to_string(),
221                }
222            }
223            mobench_sdk::BenchError::UnknownFunction(name) => {
224                BenchError::UnknownFunction { name }
225            }
226            _ => BenchError::ExecutionFailed {
227                reason: err.to_string(),
228            },
229        }
230    }
231}
232
233/// Runs a benchmark by name with the given specification
234///
235/// This is the main FFI entry point called from mobile platforms.
236#[uniffi::export]
237pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
238    let sdk_spec: mobench_sdk::BenchSpec = spec.into();
239    let report = mobench_sdk::run_benchmark(sdk_spec)?;
240    Ok(report.into())
241}
242
243// Generate UniFFI scaffolding
244uniffi::setup_scaffolding!();
245"#;
246
247    let lib_rs = render_template(
248        lib_rs_template,
249        &[TemplateVar {
250            name: "USER_CRATE",
251            value: project_name.replace('-', "_"),
252        }],
253    );
254    fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
255
256    // Generate build.rs
257    let build_rs = r#"fn main() {
258    uniffi::generate_scaffolding("src/lib.rs").unwrap();
259}
260"#;
261
262    fs::write(crate_dir.join("build.rs"), build_rs)?;
263
264    // Generate uniffi-bindgen binary (used by mobench build)
265    let bin_dir = crate_dir.join("src/bin");
266    fs::create_dir_all(&bin_dir)?;
267    let uniffi_bindgen_rs = r#"fn main() {
268    uniffi::uniffi_bindgen_main()
269}
270"#;
271    fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
272
273    Ok(())
274}
275
276/// Generates Android project structure from templates
277///
278/// This function can be called standalone to generate just the Android
279/// project scaffolding, useful for auto-generation during build.
280///
281/// # Arguments
282///
283/// * `output_dir` - Directory to write the `android/` project into
284/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
285/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
286pub fn generate_android_project(
287    output_dir: &Path,
288    project_slug: &str,
289    default_function: &str,
290) -> Result<(), BenchError> {
291    let target_dir = output_dir.join("android");
292    let library_name = project_slug.replace('-', "_");
293    let project_pascal = to_pascal_case(project_slug);
294    // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS
295    // This ensures both platforms use the same naming convention: "benchmobile" not "bench-mobile"
296    let package_id_component = sanitize_bundle_id_component(project_slug);
297    let package_name = format!("dev.world.{}", package_id_component);
298    let vars = vec![
299        TemplateVar {
300            name: "PROJECT_NAME",
301            value: project_slug.to_string(),
302        },
303        TemplateVar {
304            name: "PROJECT_NAME_PASCAL",
305            value: project_pascal.clone(),
306        },
307        TemplateVar {
308            name: "APP_NAME",
309            value: format!("{} Benchmark", project_pascal),
310        },
311        TemplateVar {
312            name: "PACKAGE_NAME",
313            value: package_name.clone(),
314        },
315        TemplateVar {
316            name: "UNIFFI_NAMESPACE",
317            value: library_name.clone(),
318        },
319        TemplateVar {
320            name: "LIBRARY_NAME",
321            value: library_name,
322        },
323        TemplateVar {
324            name: "DEFAULT_FUNCTION",
325            value: default_function.to_string(),
326        },
327    ];
328    render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
329
330    // Move Kotlin files to the correct package directory structure
331    // The package "dev.world.{project_slug}" maps to directory "dev/world/{project_slug}/"
332    move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
333
334    Ok(())
335}
336
337/// Moves Kotlin source files to the correct package directory structure
338///
339/// Android requires source files to be in directories matching their package declaration.
340/// For example, a file with `package dev.world.my_project` must be in
341/// `app/src/main/java/dev/world/my_project/`.
342///
343/// This function moves:
344/// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/`
345/// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/`
346fn move_kotlin_files_to_package_dir(android_dir: &Path, package_name: &str) -> Result<(), BenchError> {
347    // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project")
348    let package_path = package_name.replace('.', "/");
349
350    // Move main source files
351    let main_java_dir = android_dir.join("app/src/main/java");
352    let main_package_dir = main_java_dir.join(&package_path);
353    move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
354
355    // Move test source files
356    let test_java_dir = android_dir.join("app/src/androidTest/java");
357    let test_package_dir = test_java_dir.join(&package_path);
358    move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
359
360    Ok(())
361}
362
363/// Moves a single Kotlin file from source directory to package directory
364fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
365    let src_file = src_dir.join(filename);
366    if !src_file.exists() {
367        // File doesn't exist in source, nothing to move
368        return Ok(());
369    }
370
371    // Create the package directory if it doesn't exist
372    fs::create_dir_all(dest_dir).map_err(|e| {
373        BenchError::Build(format!(
374            "Failed to create package directory {:?}: {}",
375            dest_dir, e
376        ))
377    })?;
378
379    let dest_file = dest_dir.join(filename);
380
381    // Move the file (copy + delete for cross-filesystem compatibility)
382    fs::copy(&src_file, &dest_file).map_err(|e| {
383        BenchError::Build(format!(
384            "Failed to copy {} to {:?}: {}",
385            filename, dest_file, e
386        ))
387    })?;
388
389    fs::remove_file(&src_file).map_err(|e| {
390        BenchError::Build(format!(
391            "Failed to remove original file {:?}: {}",
392            src_file, e
393        ))
394    })?;
395
396    Ok(())
397}
398
399/// Generates iOS project structure from templates
400///
401/// This function can be called standalone to generate just the iOS
402/// project scaffolding, useful for auto-generation during build.
403///
404/// # Arguments
405///
406/// * `output_dir` - Directory to write the `ios/` project into
407/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
408/// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile")
409/// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench")
410/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
411pub fn generate_ios_project(
412    output_dir: &Path,
413    project_slug: &str,
414    project_pascal: &str,
415    bundle_prefix: &str,
416    default_function: &str,
417) -> Result<(), BenchError> {
418    let target_dir = output_dir.join("ios");
419    // Sanitize bundle ID components to ensure they only contain alphanumeric characters
420    // iOS bundle identifiers should not contain hyphens or underscores
421    let sanitized_bundle_prefix = {
422        let parts: Vec<&str> = bundle_prefix.split('.').collect();
423        parts.iter()
424            .map(|part| sanitize_bundle_id_component(part))
425            .collect::<Vec<_>>()
426            .join(".")
427    };
428    // Use the actual app name (project_pascal, e.g., "BenchRunner") for the bundle ID suffix,
429    // not the crate name again. This prevents duplication like "dev.world.benchmobile.benchmobile"
430    // and produces the correct "dev.world.benchmobile.BenchRunner"
431    let vars = vec![
432        TemplateVar {
433            name: "DEFAULT_FUNCTION",
434            value: default_function.to_string(),
435        },
436        TemplateVar {
437            name: "PROJECT_NAME_PASCAL",
438            value: project_pascal.to_string(),
439        },
440        TemplateVar {
441            name: "BUNDLE_ID_PREFIX",
442            value: sanitized_bundle_prefix.clone(),
443        },
444        TemplateVar {
445            name: "BUNDLE_ID",
446            value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
447        },
448        TemplateVar {
449            name: "LIBRARY_NAME",
450            value: project_slug.replace('-', "_"),
451        },
452    ];
453    render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
454    Ok(())
455}
456
457/// Generates bench-config.toml configuration file
458fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
459    let config_target = match config.target {
460        Target::Ios => "ios",
461        Target::Android | Target::Both => "android",
462    };
463    let config_content = format!(
464        r#"# mobench configuration
465# This file controls how benchmarks are executed on devices.
466
467target = "{}"
468function = "example_fibonacci"
469iterations = 100
470warmup = 10
471device_matrix = "device-matrix.yaml"
472device_tags = ["default"]
473
474[browserstack]
475app_automate_username = "${{BROWSERSTACK_USERNAME}}"
476app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
477project = "{}-benchmarks"
478
479[ios_xcuitest]
480app = "target/ios/BenchRunner.ipa"
481test_suite = "target/ios/BenchRunnerUITests.zip"
482"#,
483        config_target, config.project_name
484    );
485
486    fs::write(output_dir.join("bench-config.toml"), config_content)?;
487
488    Ok(())
489}
490
491/// Generates example benchmark functions
492fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
493    let examples_dir = output_dir.join("benches");
494    fs::create_dir_all(&examples_dir)?;
495
496    let example_content = r#"//! Example benchmarks
497//!
498//! This file demonstrates how to write benchmarks with mobench-sdk.
499
500use mobench_sdk::benchmark;
501
502/// Simple benchmark example
503#[benchmark]
504fn example_fibonacci() {
505    let result = fibonacci(30);
506    std::hint::black_box(result);
507}
508
509/// Another example with a loop
510#[benchmark]
511fn example_sum() {
512    let mut sum = 0u64;
513    for i in 0..10000 {
514        sum = sum.wrapping_add(i);
515    }
516    std::hint::black_box(sum);
517}
518
519// Helper function (not benchmarked)
520fn fibonacci(n: u32) -> u64 {
521    match n {
522        0 => 0,
523        1 => 1,
524        _ => {
525            let mut a = 0u64;
526            let mut b = 1u64;
527            for _ in 2..=n {
528                let next = a.wrapping_add(b);
529                a = b;
530                b = next;
531            }
532            b
533        }
534    }
535}
536"#;
537
538    fs::write(examples_dir.join("example.rs"), example_content)?;
539
540    Ok(())
541}
542
543/// File extensions that should be processed for template variable substitution
544const TEMPLATE_EXTENSIONS: &[&str] = &[
545    "gradle", "xml", "kt", "java", "swift", "yml", "yaml", "json", "toml", "md", "txt", "h", "m",
546    "plist", "pbxproj", "xcscheme", "xcworkspacedata", "entitlements", "modulemap",
547];
548
549fn render_dir(
550    dir: &Dir,
551    out_root: &Path,
552    vars: &[TemplateVar],
553) -> Result<(), BenchError> {
554    for entry in dir.entries() {
555        match entry {
556            DirEntry::Dir(sub) => {
557                // Skip cache directories
558                if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
559                    continue;
560                }
561                render_dir(sub, out_root, vars)?;
562            }
563            DirEntry::File(file) => {
564                if file.path().components().any(|c| c.as_os_str() == ".gradle") {
565                    continue;
566                }
567                // file.path() returns the full relative path from the embedded dir root
568                let mut relative = file.path().to_path_buf();
569                let mut contents = file.contents().to_vec();
570
571                // Check if file has .template extension (explicit template)
572                let is_explicit_template = relative
573                    .extension()
574                    .map(|ext| ext == "template")
575                    .unwrap_or(false);
576
577                // Check if file is a text file that should be processed for templates
578                let should_render = is_explicit_template || is_template_file(&relative);
579
580                if is_explicit_template {
581                    // Remove .template extension from output filename
582                    relative.set_extension("");
583                }
584
585                if should_render {
586                    if let Ok(text) = std::str::from_utf8(&contents) {
587                        let rendered = render_template(text, vars);
588                        // Validate that all template variables were replaced
589                        validate_no_unreplaced_placeholders(&rendered, &relative)?;
590                        contents = rendered.into_bytes();
591                    }
592                }
593
594                let out_path = out_root.join(relative);
595                if let Some(parent) = out_path.parent() {
596                    fs::create_dir_all(parent)?;
597                }
598                fs::write(&out_path, contents)?;
599            }
600        }
601    }
602    Ok(())
603}
604
605/// Checks if a file should be processed for template variable substitution
606/// based on its extension
607fn is_template_file(path: &Path) -> bool {
608    // Check for .template extension on any file
609    if let Some(ext) = path.extension() {
610        if ext == "template" {
611            return true;
612        }
613        // Check if the base extension is in our list
614        if let Some(ext_str) = ext.to_str() {
615            return TEMPLATE_EXTENSIONS.contains(&ext_str);
616        }
617    }
618    // Also check the filename without the .template extension
619    if let Some(stem) = path.file_stem() {
620        let stem_path = Path::new(stem);
621        if let Some(ext) = stem_path.extension() {
622            if let Some(ext_str) = ext.to_str() {
623                return TEMPLATE_EXTENSIONS.contains(&ext_str);
624            }
625        }
626    }
627    false
628}
629
630/// Validates that no unreplaced template placeholders remain in the rendered content
631fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
632    // Find all {{...}} patterns
633    let mut pos = 0;
634    let mut unreplaced = Vec::new();
635
636    while let Some(start) = content[pos..].find("{{") {
637        let abs_start = pos + start;
638        if let Some(end) = content[abs_start..].find("}}") {
639            let placeholder = &content[abs_start..abs_start + end + 2];
640            // Extract just the variable name
641            let var_name = &content[abs_start + 2..abs_start + end];
642            // Skip placeholders that look like Gradle variable syntax (e.g., ${...})
643            // or other non-template patterns
644            if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
645                unreplaced.push(placeholder.to_string());
646            }
647            pos = abs_start + end + 2;
648        } else {
649            break;
650        }
651    }
652
653    if !unreplaced.is_empty() {
654        return Err(BenchError::Build(format!(
655            "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
656             This is a bug in mobench-sdk. Please report it at:\n\
657             https://github.com/worldcoin/mobile-bench-rs/issues",
658            file_path, unreplaced
659        )));
660    }
661
662    Ok(())
663}
664
665fn render_template(input: &str, vars: &[TemplateVar]) -> String {
666    let mut output = input.to_string();
667    for var in vars {
668        output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
669    }
670    output
671}
672
673/// Sanitizes a string to be a valid iOS bundle identifier component
674///
675/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9),
676/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency,
677/// this function converts all non-alphanumeric characters to lowercase letters only.
678///
679/// Examples:
680/// - "bench-mobile" -> "benchmobile"
681/// - "bench_mobile" -> "benchmobile"
682/// - "my-project_name" -> "myprojectname"
683pub fn sanitize_bundle_id_component(name: &str) -> String {
684    name.chars()
685        .filter(|c| c.is_ascii_alphanumeric())
686        .collect::<String>()
687        .to_lowercase()
688}
689
690fn sanitize_package_name(name: &str) -> String {
691    name.chars()
692        .map(|c| {
693            if c.is_ascii_alphanumeric() {
694                c.to_ascii_lowercase()
695            } else {
696                '-'
697            }
698        })
699        .collect::<String>()
700        .trim_matches('-')
701        .replace("--", "-")
702}
703
704/// Converts a string to PascalCase
705pub fn to_pascal_case(input: &str) -> String {
706    input
707        .split(|c: char| !c.is_ascii_alphanumeric())
708        .filter(|s| !s.is_empty())
709        .map(|s| {
710            let mut chars = s.chars();
711            let first = chars.next().unwrap().to_ascii_uppercase();
712            let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
713            format!("{}{}", first, rest)
714        })
715        .collect::<String>()
716}
717
718/// Checks if the Android project scaffolding exists at the given output directory
719///
720/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists.
721pub fn android_project_exists(output_dir: &Path) -> bool {
722    let android_dir = output_dir.join("android");
723    android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
724}
725
726/// Checks if the iOS project scaffolding exists at the given output directory
727///
728/// Returns true if the `ios/BenchRunner/project.yml` file exists.
729pub fn ios_project_exists(output_dir: &Path) -> bool {
730    output_dir.join("ios/BenchRunner/project.yml").exists()
731}
732
733/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
734///
735/// This function looks for functions marked with the `#[benchmark]` attribute and returns
736/// the first one found in the format `{crate_name}::{function_name}`.
737///
738/// # Arguments
739///
740/// * `crate_dir` - Path to the crate directory containing Cargo.toml
741/// * `crate_name` - Name of the crate (used as prefix for the function name)
742///
743/// # Returns
744///
745/// * `Some(String)` - The detected function name in format `crate_name::function_name`
746/// * `None` - If no benchmark functions are found or if the file cannot be read
747pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
748    let lib_rs = crate_dir.join("src/lib.rs");
749    if !lib_rs.exists() {
750        return None;
751    }
752
753    let file = fs::File::open(&lib_rs).ok()?;
754    let reader = BufReader::new(file);
755
756    let mut found_benchmark_attr = false;
757    let crate_name_normalized = crate_name.replace('-', "_");
758
759    for line in reader.lines().map_while(Result::ok) {
760        let trimmed = line.trim();
761
762        // Check for #[benchmark] attribute
763        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
764            found_benchmark_attr = true;
765            continue;
766        }
767
768        // If we found a benchmark attribute, look for the function definition
769        if found_benchmark_attr {
770            // Look for "fn function_name" or "pub fn function_name"
771            if let Some(fn_pos) = trimmed.find("fn ") {
772                let after_fn = &trimmed[fn_pos + 3..];
773                // Extract function name (until '(' or whitespace)
774                let fn_name: String = after_fn
775                    .chars()
776                    .take_while(|c| c.is_alphanumeric() || *c == '_')
777                    .collect();
778
779                if !fn_name.is_empty() {
780                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
781                }
782            }
783            // Reset if we hit a line that's not a function definition
784            // (could be another attribute or comment)
785            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
786                found_benchmark_attr = false;
787            }
788        }
789    }
790
791    None
792}
793
794/// Resolves the default benchmark function for a project
795///
796/// This function attempts to auto-detect benchmark functions from the crate's source.
797/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
798///
799/// # Arguments
800///
801/// * `project_root` - Root directory of the project
802/// * `crate_name` - Name of the benchmark crate
803/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
804///
805/// # Returns
806///
807/// The default function name in format `crate_name::function_name`
808pub fn resolve_default_function(
809    project_root: &Path,
810    crate_name: &str,
811    crate_dir: Option<&Path>,
812) -> String {
813    let crate_name_normalized = crate_name.replace('-', "_");
814
815    // Try to find the crate directory
816    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
817        vec![dir.to_path_buf()]
818    } else {
819        vec![
820            project_root.join("bench-mobile"),
821            project_root.join("crates").join(crate_name),
822            project_root.to_path_buf(),
823        ]
824    };
825
826    // Try to detect benchmarks from each potential location
827    for dir in &search_dirs {
828        if dir.join("Cargo.toml").exists() {
829            if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
830                return detected;
831            }
832        }
833    }
834
835    // Fallback: use a sensible default based on crate name
836    format!("{}::example_benchmark", crate_name_normalized)
837}
838
839/// Auto-generates Android project scaffolding from a crate name
840///
841/// This is a convenience function that derives template variables from the
842/// crate name and generates the Android project structure. It auto-detects
843/// the default benchmark function from the crate's source code.
844///
845/// # Arguments
846///
847/// * `output_dir` - Directory to write the `android/` project into
848/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
849pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
850    ensure_android_project_with_options(output_dir, crate_name, None, None)
851}
852
853/// Auto-generates Android project scaffolding with additional options
854///
855/// This is a more flexible version of `ensure_android_project` that allows
856/// specifying a custom default function and/or crate directory.
857///
858/// # Arguments
859///
860/// * `output_dir` - Directory to write the `android/` project into
861/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
862/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
863/// * `crate_dir` - Optional explicit crate directory for benchmark detection
864pub fn ensure_android_project_with_options(
865    output_dir: &Path,
866    crate_name: &str,
867    project_root: Option<&Path>,
868    crate_dir: Option<&Path>,
869) -> Result<(), BenchError> {
870    if android_project_exists(output_dir) {
871        return Ok(());
872    }
873
874    println!("Android project not found, generating scaffolding...");
875    let project_slug = crate_name.replace('-', "_");
876
877    // Resolve the default function by auto-detecting from source
878    let effective_root = project_root.unwrap_or_else(|| {
879        output_dir.parent().unwrap_or(output_dir)
880    });
881    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
882
883    generate_android_project(output_dir, &project_slug, &default_function)?;
884    println!("  Generated Android project at {:?}", output_dir.join("android"));
885    println!("  Default benchmark function: {}", default_function);
886    Ok(())
887}
888
889/// Auto-generates iOS project scaffolding from a crate name
890///
891/// This is a convenience function that derives template variables from the
892/// crate name and generates the iOS project structure. It auto-detects
893/// the default benchmark function from the crate's source code.
894///
895/// # Arguments
896///
897/// * `output_dir` - Directory to write the `ios/` project into
898/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
899pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
900    ensure_ios_project_with_options(output_dir, crate_name, None, None)
901}
902
903/// Auto-generates iOS project scaffolding with additional options
904///
905/// This is a more flexible version of `ensure_ios_project` that allows
906/// specifying a custom default function and/or crate directory.
907///
908/// # Arguments
909///
910/// * `output_dir` - Directory to write the `ios/` project into
911/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
912/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
913/// * `crate_dir` - Optional explicit crate directory for benchmark detection
914pub fn ensure_ios_project_with_options(
915    output_dir: &Path,
916    crate_name: &str,
917    project_root: Option<&Path>,
918    crate_dir: Option<&Path>,
919) -> Result<(), BenchError> {
920    if ios_project_exists(output_dir) {
921        return Ok(());
922    }
923
924    println!("iOS project not found, generating scaffolding...");
925    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
926    let project_pascal = "BenchRunner";
927    // Derive library name and bundle prefix from crate name
928    let library_name = crate_name.replace('-', "_");
929    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
930    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
931    let bundle_id_component = sanitize_bundle_id_component(crate_name);
932    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
933
934    // Resolve the default function by auto-detecting from source
935    let effective_root = project_root.unwrap_or_else(|| {
936        output_dir.parent().unwrap_or(output_dir)
937    });
938    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
939
940    generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix, &default_function)?;
941    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
942    println!("  Default benchmark function: {}", default_function);
943    Ok(())
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use std::env;
950
951    #[test]
952    fn test_generate_bench_mobile_crate() {
953        let temp_dir = env::temp_dir().join("mobench-sdk-test");
954        fs::create_dir_all(&temp_dir).unwrap();
955
956        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
957        assert!(result.is_ok());
958
959        // Verify files were created
960        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
961        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
962        assert!(temp_dir.join("bench-mobile/build.rs").exists());
963
964        // Cleanup
965        fs::remove_dir_all(&temp_dir).ok();
966    }
967
968    #[test]
969    fn test_generate_android_project_no_unreplaced_placeholders() {
970        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
971        // Clean up any previous test run
972        let _ = fs::remove_dir_all(&temp_dir);
973        fs::create_dir_all(&temp_dir).unwrap();
974
975        let result = generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
976        assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
977
978        // Verify key files exist
979        let android_dir = temp_dir.join("android");
980        assert!(android_dir.join("settings.gradle").exists());
981        assert!(android_dir.join("app/build.gradle").exists());
982        assert!(android_dir.join("app/src/main/AndroidManifest.xml").exists());
983        assert!(android_dir.join("app/src/main/res/values/strings.xml").exists());
984        assert!(android_dir.join("app/src/main/res/values/themes.xml").exists());
985
986        // Verify no unreplaced placeholders remain in generated files
987        let files_to_check = [
988            "settings.gradle",
989            "app/build.gradle",
990            "app/src/main/AndroidManifest.xml",
991            "app/src/main/res/values/strings.xml",
992            "app/src/main/res/values/themes.xml",
993        ];
994
995        for file in files_to_check {
996            let path = android_dir.join(file);
997            let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
998
999            // Check for unreplaced placeholders
1000            let has_placeholder = contents.contains("{{") && contents.contains("}}");
1001            assert!(
1002                !has_placeholder,
1003                "File {} contains unreplaced template placeholders: {}",
1004                file,
1005                contents
1006            );
1007        }
1008
1009        // Verify specific substitutions were made
1010        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1011        assert!(
1012            settings.contains("my-bench-project-android") || settings.contains("my_bench_project-android"),
1013            "settings.gradle should contain project name"
1014        );
1015
1016        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1017        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
1018        assert!(
1019            build_gradle.contains("dev.world.mybenchproject"),
1020            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1021        );
1022
1023        let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1024        assert!(
1025            manifest.contains("Theme.MyBenchProject"),
1026            "AndroidManifest.xml should contain PascalCase theme name"
1027        );
1028
1029        let strings = fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1030        assert!(
1031            strings.contains("Benchmark"),
1032            "strings.xml should contain app name with Benchmark"
1033        );
1034
1035        // Verify Kotlin files are in the correct package directory structure
1036        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
1037        let main_activity_path = android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1038        assert!(
1039            main_activity_path.exists(),
1040            "MainActivity.kt should be in package directory: {:?}",
1041            main_activity_path
1042        );
1043
1044        let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1045        assert!(
1046            test_activity_path.exists(),
1047            "MainActivityTest.kt should be in package directory: {:?}",
1048            test_activity_path
1049        );
1050
1051        // Verify the files are NOT in the root java directory
1052        assert!(
1053            !android_dir.join("app/src/main/java/MainActivity.kt").exists(),
1054            "MainActivity.kt should not be in root java directory"
1055        );
1056        assert!(
1057            !android_dir.join("app/src/androidTest/java/MainActivityTest.kt").exists(),
1058            "MainActivityTest.kt should not be in root java directory"
1059        );
1060
1061        // Cleanup
1062        fs::remove_dir_all(&temp_dir).ok();
1063    }
1064
1065    #[test]
1066    fn test_is_template_file() {
1067        assert!(is_template_file(Path::new("settings.gradle")));
1068        assert!(is_template_file(Path::new("app/build.gradle")));
1069        assert!(is_template_file(Path::new("AndroidManifest.xml")));
1070        assert!(is_template_file(Path::new("strings.xml")));
1071        assert!(is_template_file(Path::new("MainActivity.kt.template")));
1072        assert!(is_template_file(Path::new("project.yml")));
1073        assert!(is_template_file(Path::new("Info.plist")));
1074        assert!(!is_template_file(Path::new("libfoo.so")));
1075        assert!(!is_template_file(Path::new("image.png")));
1076    }
1077
1078    #[test]
1079    fn test_validate_no_unreplaced_placeholders() {
1080        // Should pass with no placeholders
1081        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1082
1083        // Should pass with Gradle variables (not our placeholders)
1084        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1085
1086        // Should fail with unreplaced template placeholders
1087        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1088        assert!(result.is_err());
1089        let err = result.unwrap_err().to_string();
1090        assert!(err.contains("{{NAME}}"));
1091    }
1092
1093    #[test]
1094    fn test_to_pascal_case() {
1095        assert_eq!(to_pascal_case("my-project"), "MyProject");
1096        assert_eq!(to_pascal_case("my_project"), "MyProject");
1097        assert_eq!(to_pascal_case("myproject"), "Myproject");
1098        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1099    }
1100
1101    #[test]
1102    fn test_detect_default_function_finds_benchmark() {
1103        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1104        let _ = fs::remove_dir_all(&temp_dir);
1105        fs::create_dir_all(temp_dir.join("src")).unwrap();
1106
1107        // Create a lib.rs with a benchmark function
1108        let lib_content = r#"
1109use mobench_sdk::benchmark;
1110
1111/// Some docs
1112#[benchmark]
1113fn my_benchmark_func() {
1114    // benchmark code
1115}
1116
1117fn helper_func() {}
1118"#;
1119        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1120        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1121
1122        let result = detect_default_function(&temp_dir, "my_crate");
1123        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1124
1125        // Cleanup
1126        fs::remove_dir_all(&temp_dir).ok();
1127    }
1128
1129    #[test]
1130    fn test_detect_default_function_no_benchmark() {
1131        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1132        let _ = fs::remove_dir_all(&temp_dir);
1133        fs::create_dir_all(temp_dir.join("src")).unwrap();
1134
1135        // Create a lib.rs without benchmark functions
1136        let lib_content = r#"
1137fn regular_function() {
1138    // no benchmark here
1139}
1140"#;
1141        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1142
1143        let result = detect_default_function(&temp_dir, "my_crate");
1144        assert!(result.is_none());
1145
1146        // Cleanup
1147        fs::remove_dir_all(&temp_dir).ok();
1148    }
1149
1150    #[test]
1151    fn test_detect_default_function_pub_fn() {
1152        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1153        let _ = fs::remove_dir_all(&temp_dir);
1154        fs::create_dir_all(temp_dir.join("src")).unwrap();
1155
1156        // Create a lib.rs with a public benchmark function
1157        let lib_content = r#"
1158#[benchmark]
1159pub fn public_bench() {
1160    // benchmark code
1161}
1162"#;
1163        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1164
1165        let result = detect_default_function(&temp_dir, "test-crate");
1166        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1167
1168        // Cleanup
1169        fs::remove_dir_all(&temp_dir).ok();
1170    }
1171
1172    #[test]
1173    fn test_resolve_default_function_fallback() {
1174        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1175        let _ = fs::remove_dir_all(&temp_dir);
1176        fs::create_dir_all(&temp_dir).unwrap();
1177
1178        // No lib.rs exists, should fall back to default
1179        let result = resolve_default_function(&temp_dir, "my-crate", None);
1180        assert_eq!(result, "my_crate::example_benchmark");
1181
1182        // Cleanup
1183        fs::remove_dir_all(&temp_dir).ok();
1184    }
1185
1186    #[test]
1187    fn test_sanitize_bundle_id_component() {
1188        // Hyphens should be removed
1189        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1190        // Underscores should be removed
1191        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1192        // Mixed separators should all be removed
1193        assert_eq!(sanitize_bundle_id_component("my-project_name"), "myprojectname");
1194        // Already valid should remain unchanged (but lowercase)
1195        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1196        // Numbers should be preserved
1197        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1198        // Uppercase should be lowercased
1199        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1200        // Complex case
1201        assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123");
1202    }
1203
1204    #[test]
1205    fn test_generate_ios_project_bundle_id_not_duplicated() {
1206        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1207        // Clean up any previous test run
1208        let _ = fs::remove_dir_all(&temp_dir);
1209        fs::create_dir_all(&temp_dir).unwrap();
1210
1211        // Use a crate name that would previously cause duplication
1212        let crate_name = "bench-mobile";
1213        let bundle_prefix = "dev.world.benchmobile";
1214        let project_pascal = "BenchRunner";
1215
1216        let result = generate_ios_project(
1217            &temp_dir,
1218            crate_name,
1219            project_pascal,
1220            bundle_prefix,
1221            "bench_mobile::test_func",
1222        );
1223        assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err());
1224
1225        // Verify project.yml was created
1226        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1227        assert!(project_yml_path.exists(), "project.yml should exist");
1228
1229        // Read and verify the bundle ID is correct (not duplicated)
1230        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1231
1232        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
1233        // NOT "dev.world.benchmobile.benchmobile"
1234        assert!(
1235            project_yml.contains("dev.world.benchmobile.BenchRunner"),
1236            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1237            project_yml
1238        );
1239        assert!(
1240            !project_yml.contains("dev.world.benchmobile.benchmobile"),
1241            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1242            project_yml
1243        );
1244
1245        // Cleanup
1246        fs::remove_dir_all(&temp_dir).ok();
1247    }
1248
1249    #[test]
1250    fn test_cross_platform_naming_consistency() {
1251        // Test that Android and iOS use the same naming convention for package/bundle IDs
1252        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1253        let _ = fs::remove_dir_all(&temp_dir);
1254        fs::create_dir_all(&temp_dir).unwrap();
1255
1256        let project_name = "bench-mobile";
1257
1258        // Generate Android project
1259        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1260        assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
1261
1262        // Generate iOS project (mimicking how ensure_ios_project does it)
1263        let bundle_id_component = sanitize_bundle_id_component(project_name);
1264        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1265        let result = generate_ios_project(
1266            &temp_dir,
1267            &project_name.replace('-', "_"),
1268            "BenchRunner",
1269            &bundle_prefix,
1270            "bench_mobile::test_func",
1271        );
1272        assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err());
1273
1274        // Read Android build.gradle to extract package name
1275        let android_build_gradle = fs::read_to_string(
1276            temp_dir.join("android/app/build.gradle")
1277        ).expect("Failed to read Android build.gradle");
1278
1279        // Read iOS project.yml to extract bundle ID prefix
1280        let ios_project_yml = fs::read_to_string(
1281            temp_dir.join("ios/BenchRunner/project.yml")
1282        ).expect("Failed to read iOS project.yml");
1283
1284        // Both should use "benchmobile" (without hyphens or underscores)
1285        // Android: namespace = "dev.world.benchmobile"
1286        // iOS: bundleIdPrefix: dev.world.benchmobile
1287        assert!(
1288            android_build_gradle.contains("dev.world.benchmobile"),
1289            "Android package should be 'dev.world.benchmobile', got:\n{}",
1290            android_build_gradle
1291        );
1292        assert!(
1293            ios_project_yml.contains("dev.world.benchmobile"),
1294            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1295            ios_project_yml
1296        );
1297
1298        // Ensure Android doesn't use hyphens or underscores in the package ID component
1299        assert!(
1300            !android_build_gradle.contains("dev.world.bench-mobile"),
1301            "Android package should NOT contain hyphens"
1302        );
1303        assert!(
1304            !android_build_gradle.contains("dev.world.bench_mobile"),
1305            "Android package should NOT contain underscores"
1306        );
1307
1308        // Cleanup
1309        fs::remove_dir_all(&temp_dir).ok();
1310    }
1311
1312    #[test]
1313    fn test_cross_platform_version_consistency() {
1314        // Test that Android and iOS use the same version strings
1315        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1316        let _ = fs::remove_dir_all(&temp_dir);
1317        fs::create_dir_all(&temp_dir).unwrap();
1318
1319        let project_name = "test-project";
1320
1321        // Generate Android project
1322        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1323        assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
1324
1325        // Generate iOS project
1326        let bundle_id_component = sanitize_bundle_id_component(project_name);
1327        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1328        let result = generate_ios_project(
1329            &temp_dir,
1330            &project_name.replace('-', "_"),
1331            "BenchRunner",
1332            &bundle_prefix,
1333            "test_project::test_func",
1334        );
1335        assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err());
1336
1337        // Read Android build.gradle
1338        let android_build_gradle = fs::read_to_string(
1339            temp_dir.join("android/app/build.gradle")
1340        ).expect("Failed to read Android build.gradle");
1341
1342        // Read iOS project.yml
1343        let ios_project_yml = fs::read_to_string(
1344            temp_dir.join("ios/BenchRunner/project.yml")
1345        ).expect("Failed to read iOS project.yml");
1346
1347        // Both should use version "1.0.0"
1348        assert!(
1349            android_build_gradle.contains("versionName \"1.0.0\""),
1350            "Android versionName should be '1.0.0', got:\n{}",
1351            android_build_gradle
1352        );
1353        assert!(
1354            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1355            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1356            ios_project_yml
1357        );
1358
1359        // Cleanup
1360        fs::remove_dir_all(&temp_dir).ok();
1361    }
1362
1363    #[test]
1364    fn test_bundle_id_prefix_consistency() {
1365        // Test that the bundle ID prefix format is consistent across platforms
1366        let test_cases = vec![
1367            ("my-project", "dev.world.myproject"),
1368            ("bench_mobile", "dev.world.benchmobile"),
1369            ("TestApp", "dev.world.testapp"),
1370            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1371            ("app_with_many_underscores", "dev.world.appwithmanyunderscores"),
1372        ];
1373
1374        for (input, expected_prefix) in test_cases {
1375            let sanitized = sanitize_bundle_id_component(input);
1376            let full_prefix = format!("dev.world.{}", sanitized);
1377            assert_eq!(
1378                full_prefix, expected_prefix,
1379                "For input '{}', expected '{}' but got '{}'",
1380                input, expected_prefix, full_prefix
1381            );
1382        }
1383    }
1384}