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