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/// Checks whether an existing iOS project was generated for the given library name.
763///
764/// Returns `false` if the xcframework reference in `project.yml` doesn't match,
765/// which means the project needs to be regenerated for the new crate.
766fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
767    let project_yml = output_dir.join("ios/BenchRunner/project.yml");
768    let Ok(content) = std::fs::read_to_string(&project_yml) else {
769        return false;
770    };
771    let expected = format!("../{}.xcframework", library_name);
772    content.contains(&expected)
773}
774
775/// Checks whether an existing Android project was generated for the given library name.
776///
777/// Returns `false` if the JNI library name in `build.gradle` doesn't match,
778/// which means the project needs to be regenerated for the new crate.
779fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
780    let build_gradle = output_dir.join("android/app/build.gradle");
781    let Ok(content) = std::fs::read_to_string(&build_gradle) else {
782        return false;
783    };
784    let expected = format!("lib{}.so", library_name);
785    content.contains(&expected)
786}
787
788/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
789///
790/// This function looks for functions marked with the `#[benchmark]` attribute and returns
791/// the first one found in the format `{crate_name}::{function_name}`.
792///
793/// # Arguments
794///
795/// * `crate_dir` - Path to the crate directory containing Cargo.toml
796/// * `crate_name` - Name of the crate (used as prefix for the function name)
797///
798/// # Returns
799///
800/// * `Some(String)` - The detected function name in format `crate_name::function_name`
801/// * `None` - If no benchmark functions are found or if the file cannot be read
802pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
803    let lib_rs = crate_dir.join("src/lib.rs");
804    if !lib_rs.exists() {
805        return None;
806    }
807
808    let file = fs::File::open(&lib_rs).ok()?;
809    let reader = BufReader::new(file);
810
811    let mut found_benchmark_attr = false;
812    let crate_name_normalized = crate_name.replace('-', "_");
813
814    for line in reader.lines().map_while(Result::ok) {
815        let trimmed = line.trim();
816
817        // Check for #[benchmark] attribute
818        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
819            found_benchmark_attr = true;
820            continue;
821        }
822
823        // If we found a benchmark attribute, look for the function definition
824        if found_benchmark_attr {
825            // Look for "fn function_name" or "pub fn function_name"
826            if let Some(fn_pos) = trimmed.find("fn ") {
827                let after_fn = &trimmed[fn_pos + 3..];
828                // Extract function name (until '(' or whitespace)
829                let fn_name: String = after_fn
830                    .chars()
831                    .take_while(|c| c.is_alphanumeric() || *c == '_')
832                    .collect();
833
834                if !fn_name.is_empty() {
835                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
836                }
837            }
838            // Reset if we hit a line that's not a function definition
839            // (could be another attribute or comment)
840            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
841                found_benchmark_attr = false;
842            }
843        }
844    }
845
846    None
847}
848
849/// Detects all benchmark functions in a crate by scanning src/lib.rs for `#[benchmark]`
850///
851/// This function looks for functions marked with the `#[benchmark]` attribute and returns
852/// all found in the format `{crate_name}::{function_name}`.
853///
854/// # Arguments
855///
856/// * `crate_dir` - Path to the crate directory containing Cargo.toml
857/// * `crate_name` - Name of the crate (used as prefix for the function names)
858///
859/// # Returns
860///
861/// A vector of benchmark function names in format `crate_name::function_name`
862pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
863    let lib_rs = crate_dir.join("src/lib.rs");
864    if !lib_rs.exists() {
865        return Vec::new();
866    }
867
868    let Ok(file) = fs::File::open(&lib_rs) else {
869        return Vec::new();
870    };
871    let reader = BufReader::new(file);
872
873    let mut benchmarks = Vec::new();
874    let mut found_benchmark_attr = false;
875    let crate_name_normalized = crate_name.replace('-', "_");
876
877    for line in reader.lines().map_while(Result::ok) {
878        let trimmed = line.trim();
879
880        // Check for #[benchmark] attribute
881        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
882            found_benchmark_attr = true;
883            continue;
884        }
885
886        // If we found a benchmark attribute, look for the function definition
887        if found_benchmark_attr {
888            // Look for "fn function_name" or "pub fn function_name"
889            if let Some(fn_pos) = trimmed.find("fn ") {
890                let after_fn = &trimmed[fn_pos + 3..];
891                // Extract function name (until '(' or whitespace)
892                let fn_name: String = after_fn
893                    .chars()
894                    .take_while(|c| c.is_alphanumeric() || *c == '_')
895                    .collect();
896
897                if !fn_name.is_empty() {
898                    benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
899                }
900                found_benchmark_attr = false;
901            }
902            // Reset if we hit a line that's not a function definition
903            // (could be another attribute or comment)
904            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
905                found_benchmark_attr = false;
906            }
907        }
908    }
909
910    benchmarks
911}
912
913/// Validates that a benchmark function exists in the crate source
914///
915/// # Arguments
916///
917/// * `crate_dir` - Path to the crate directory containing Cargo.toml
918/// * `crate_name` - Name of the crate (used as prefix for the function names)
919/// * `function_name` - The function name to validate (with or without crate prefix)
920///
921/// # Returns
922///
923/// `true` if the function is found, `false` otherwise
924pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
925    let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
926    let crate_name_normalized = crate_name.replace('-', "_");
927
928    // Normalize the function name - add crate prefix if missing
929    let normalized_name = if function_name.contains("::") {
930        function_name.to_string()
931    } else {
932        format!("{}::{}", crate_name_normalized, function_name)
933    };
934
935    benchmarks.iter().any(|b| b == &normalized_name)
936}
937
938/// Resolves the default benchmark function for a project
939///
940/// This function attempts to auto-detect benchmark functions from the crate's source.
941/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
942///
943/// # Arguments
944///
945/// * `project_root` - Root directory of the project
946/// * `crate_name` - Name of the benchmark crate
947/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
948///
949/// # Returns
950///
951/// The default function name in format `crate_name::function_name`
952pub fn resolve_default_function(
953    project_root: &Path,
954    crate_name: &str,
955    crate_dir: Option<&Path>,
956) -> String {
957    let crate_name_normalized = crate_name.replace('-', "_");
958
959    // Try to find the crate directory
960    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
961        vec![dir.to_path_buf()]
962    } else {
963        vec![
964            project_root.join("bench-mobile"),
965            project_root.join("crates").join(crate_name),
966            project_root.to_path_buf(),
967        ]
968    };
969
970    // Try to detect benchmarks from each potential location
971    for dir in &search_dirs {
972        if dir.join("Cargo.toml").exists() {
973            if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
974                return detected;
975            }
976        }
977    }
978
979    // Fallback: use a sensible default based on crate name
980    format!("{}::example_benchmark", crate_name_normalized)
981}
982
983/// Auto-generates Android project scaffolding from a crate name
984///
985/// This is a convenience function that derives template variables from the
986/// crate name and generates the Android project structure. It auto-detects
987/// the default benchmark function from the crate's source code.
988///
989/// # Arguments
990///
991/// * `output_dir` - Directory to write the `android/` project into
992/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
993pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
994    ensure_android_project_with_options(output_dir, crate_name, None, None)
995}
996
997/// Auto-generates Android project scaffolding with additional options
998///
999/// This is a more flexible version of `ensure_android_project` that allows
1000/// specifying a custom default function and/or crate directory.
1001///
1002/// # Arguments
1003///
1004/// * `output_dir` - Directory to write the `android/` project into
1005/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1006/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1007/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1008pub fn ensure_android_project_with_options(
1009    output_dir: &Path,
1010    crate_name: &str,
1011    project_root: Option<&Path>,
1012    crate_dir: Option<&Path>,
1013) -> Result<(), BenchError> {
1014    let library_name = crate_name.replace('-', "_");
1015    if android_project_exists(output_dir)
1016        && android_project_matches_library(output_dir, &library_name)
1017    {
1018        return Ok(());
1019    }
1020
1021    println!("Android project not found, generating scaffolding...");
1022    let project_slug = crate_name.replace('-', "_");
1023
1024    // Resolve the default function by auto-detecting from source
1025    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1026    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1027
1028    generate_android_project(output_dir, &project_slug, &default_function)?;
1029    println!(
1030        "  Generated Android project at {:?}",
1031        output_dir.join("android")
1032    );
1033    println!("  Default benchmark function: {}", default_function);
1034    Ok(())
1035}
1036
1037/// Auto-generates iOS project scaffolding from a crate name
1038///
1039/// This is a convenience function that derives template variables from the
1040/// crate name and generates the iOS project structure. It auto-detects
1041/// the default benchmark function from the crate's source code.
1042///
1043/// # Arguments
1044///
1045/// * `output_dir` - Directory to write the `ios/` project into
1046/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1047pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1048    ensure_ios_project_with_options(output_dir, crate_name, None, None)
1049}
1050
1051/// Auto-generates iOS project scaffolding with additional options
1052///
1053/// This is a more flexible version of `ensure_ios_project` that allows
1054/// specifying a custom default function and/or crate directory.
1055///
1056/// # Arguments
1057///
1058/// * `output_dir` - Directory to write the `ios/` project into
1059/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1060/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1061/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1062pub fn ensure_ios_project_with_options(
1063    output_dir: &Path,
1064    crate_name: &str,
1065    project_root: Option<&Path>,
1066    crate_dir: Option<&Path>,
1067) -> Result<(), BenchError> {
1068    let library_name = crate_name.replace('-', "_");
1069    if ios_project_exists(output_dir) && ios_project_matches_library(output_dir, &library_name) {
1070        return Ok(());
1071    }
1072
1073    println!("iOS project not found, generating scaffolding...");
1074    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
1075    let project_pascal = "BenchRunner";
1076    // Derive library name and bundle prefix from crate name
1077    let library_name = crate_name.replace('-', "_");
1078    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
1079    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
1080    let bundle_id_component = sanitize_bundle_id_component(crate_name);
1081    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1082
1083    // Resolve the default function by auto-detecting from source
1084    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1085    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1086
1087    generate_ios_project(
1088        output_dir,
1089        &library_name,
1090        project_pascal,
1091        &bundle_prefix,
1092        &default_function,
1093    )?;
1094    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
1095    println!("  Default benchmark function: {}", default_function);
1096    Ok(())
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102    use std::env;
1103
1104    #[test]
1105    fn test_generate_bench_mobile_crate() {
1106        let temp_dir = env::temp_dir().join("mobench-sdk-test");
1107        fs::create_dir_all(&temp_dir).unwrap();
1108
1109        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1110        assert!(result.is_ok());
1111
1112        // Verify files were created
1113        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1114        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1115        assert!(temp_dir.join("bench-mobile/build.rs").exists());
1116
1117        // Cleanup
1118        fs::remove_dir_all(&temp_dir).ok();
1119    }
1120
1121    #[test]
1122    fn test_generate_android_project_no_unreplaced_placeholders() {
1123        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1124        // Clean up any previous test run
1125        let _ = fs::remove_dir_all(&temp_dir);
1126        fs::create_dir_all(&temp_dir).unwrap();
1127
1128        let result =
1129            generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1130        assert!(
1131            result.is_ok(),
1132            "generate_android_project failed: {:?}",
1133            result.err()
1134        );
1135
1136        // Verify key files exist
1137        let android_dir = temp_dir.join("android");
1138        assert!(android_dir.join("settings.gradle").exists());
1139        assert!(android_dir.join("app/build.gradle").exists());
1140        assert!(
1141            android_dir
1142                .join("app/src/main/AndroidManifest.xml")
1143                .exists()
1144        );
1145        assert!(
1146            android_dir
1147                .join("app/src/main/res/values/strings.xml")
1148                .exists()
1149        );
1150        assert!(
1151            android_dir
1152                .join("app/src/main/res/values/themes.xml")
1153                .exists()
1154        );
1155
1156        // Verify no unreplaced placeholders remain in generated files
1157        let files_to_check = [
1158            "settings.gradle",
1159            "app/build.gradle",
1160            "app/src/main/AndroidManifest.xml",
1161            "app/src/main/res/values/strings.xml",
1162            "app/src/main/res/values/themes.xml",
1163        ];
1164
1165        for file in files_to_check {
1166            let path = android_dir.join(file);
1167            let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
1168
1169            // Check for unreplaced placeholders
1170            let has_placeholder = contents.contains("{{") && contents.contains("}}");
1171            assert!(
1172                !has_placeholder,
1173                "File {} contains unreplaced template placeholders: {}",
1174                file, contents
1175            );
1176        }
1177
1178        // Verify specific substitutions were made
1179        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1180        assert!(
1181            settings.contains("my-bench-project-android")
1182                || settings.contains("my_bench_project-android"),
1183            "settings.gradle should contain project name"
1184        );
1185
1186        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1187        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
1188        assert!(
1189            build_gradle.contains("dev.world.mybenchproject"),
1190            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1191        );
1192
1193        let manifest =
1194            fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1195        assert!(
1196            manifest.contains("Theme.MyBenchProject"),
1197            "AndroidManifest.xml should contain PascalCase theme name"
1198        );
1199
1200        let strings =
1201            fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1202        assert!(
1203            strings.contains("Benchmark"),
1204            "strings.xml should contain app name with Benchmark"
1205        );
1206
1207        // Verify Kotlin files are in the correct package directory structure
1208        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
1209        let main_activity_path =
1210            android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1211        assert!(
1212            main_activity_path.exists(),
1213            "MainActivity.kt should be in package directory: {:?}",
1214            main_activity_path
1215        );
1216
1217        let test_activity_path = android_dir
1218            .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1219        assert!(
1220            test_activity_path.exists(),
1221            "MainActivityTest.kt should be in package directory: {:?}",
1222            test_activity_path
1223        );
1224
1225        // Verify the files are NOT in the root java directory
1226        assert!(
1227            !android_dir
1228                .join("app/src/main/java/MainActivity.kt")
1229                .exists(),
1230            "MainActivity.kt should not be in root java directory"
1231        );
1232        assert!(
1233            !android_dir
1234                .join("app/src/androidTest/java/MainActivityTest.kt")
1235                .exists(),
1236            "MainActivityTest.kt should not be in root java directory"
1237        );
1238
1239        // Cleanup
1240        fs::remove_dir_all(&temp_dir).ok();
1241    }
1242
1243    #[test]
1244    fn test_is_template_file() {
1245        assert!(is_template_file(Path::new("settings.gradle")));
1246        assert!(is_template_file(Path::new("app/build.gradle")));
1247        assert!(is_template_file(Path::new("AndroidManifest.xml")));
1248        assert!(is_template_file(Path::new("strings.xml")));
1249        assert!(is_template_file(Path::new("MainActivity.kt.template")));
1250        assert!(is_template_file(Path::new("project.yml")));
1251        assert!(is_template_file(Path::new("Info.plist")));
1252        assert!(!is_template_file(Path::new("libfoo.so")));
1253        assert!(!is_template_file(Path::new("image.png")));
1254    }
1255
1256    #[test]
1257    fn test_validate_no_unreplaced_placeholders() {
1258        // Should pass with no placeholders
1259        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1260
1261        // Should pass with Gradle variables (not our placeholders)
1262        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1263
1264        // Should fail with unreplaced template placeholders
1265        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1266        assert!(result.is_err());
1267        let err = result.unwrap_err().to_string();
1268        assert!(err.contains("{{NAME}}"));
1269    }
1270
1271    #[test]
1272    fn test_to_pascal_case() {
1273        assert_eq!(to_pascal_case("my-project"), "MyProject");
1274        assert_eq!(to_pascal_case("my_project"), "MyProject");
1275        assert_eq!(to_pascal_case("myproject"), "Myproject");
1276        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1277    }
1278
1279    #[test]
1280    fn test_detect_default_function_finds_benchmark() {
1281        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1282        let _ = fs::remove_dir_all(&temp_dir);
1283        fs::create_dir_all(temp_dir.join("src")).unwrap();
1284
1285        // Create a lib.rs with a benchmark function
1286        let lib_content = r#"
1287use mobench_sdk::benchmark;
1288
1289/// Some docs
1290#[benchmark]
1291fn my_benchmark_func() {
1292    // benchmark code
1293}
1294
1295fn helper_func() {}
1296"#;
1297        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1298        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1299
1300        let result = detect_default_function(&temp_dir, "my_crate");
1301        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1302
1303        // Cleanup
1304        fs::remove_dir_all(&temp_dir).ok();
1305    }
1306
1307    #[test]
1308    fn test_detect_default_function_no_benchmark() {
1309        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1310        let _ = fs::remove_dir_all(&temp_dir);
1311        fs::create_dir_all(temp_dir.join("src")).unwrap();
1312
1313        // Create a lib.rs without benchmark functions
1314        let lib_content = r#"
1315fn regular_function() {
1316    // no benchmark here
1317}
1318"#;
1319        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1320
1321        let result = detect_default_function(&temp_dir, "my_crate");
1322        assert!(result.is_none());
1323
1324        // Cleanup
1325        fs::remove_dir_all(&temp_dir).ok();
1326    }
1327
1328    #[test]
1329    fn test_detect_default_function_pub_fn() {
1330        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1331        let _ = fs::remove_dir_all(&temp_dir);
1332        fs::create_dir_all(temp_dir.join("src")).unwrap();
1333
1334        // Create a lib.rs with a public benchmark function
1335        let lib_content = r#"
1336#[benchmark]
1337pub fn public_bench() {
1338    // benchmark code
1339}
1340"#;
1341        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1342
1343        let result = detect_default_function(&temp_dir, "test-crate");
1344        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1345
1346        // Cleanup
1347        fs::remove_dir_all(&temp_dir).ok();
1348    }
1349
1350    #[test]
1351    fn test_resolve_default_function_fallback() {
1352        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1353        let _ = fs::remove_dir_all(&temp_dir);
1354        fs::create_dir_all(&temp_dir).unwrap();
1355
1356        // No lib.rs exists, should fall back to default
1357        let result = resolve_default_function(&temp_dir, "my-crate", None);
1358        assert_eq!(result, "my_crate::example_benchmark");
1359
1360        // Cleanup
1361        fs::remove_dir_all(&temp_dir).ok();
1362    }
1363
1364    #[test]
1365    fn test_sanitize_bundle_id_component() {
1366        // Hyphens should be removed
1367        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1368        // Underscores should be removed
1369        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1370        // Mixed separators should all be removed
1371        assert_eq!(
1372            sanitize_bundle_id_component("my-project_name"),
1373            "myprojectname"
1374        );
1375        // Already valid should remain unchanged (but lowercase)
1376        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1377        // Numbers should be preserved
1378        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1379        // Uppercase should be lowercased
1380        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1381        // Complex case
1382        assert_eq!(
1383            sanitize_bundle_id_component("My-Complex_Project-123"),
1384            "mycomplexproject123"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_generate_ios_project_bundle_id_not_duplicated() {
1390        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1391        // Clean up any previous test run
1392        let _ = fs::remove_dir_all(&temp_dir);
1393        fs::create_dir_all(&temp_dir).unwrap();
1394
1395        // Use a crate name that would previously cause duplication
1396        let crate_name = "bench-mobile";
1397        let bundle_prefix = "dev.world.benchmobile";
1398        let project_pascal = "BenchRunner";
1399
1400        let result = generate_ios_project(
1401            &temp_dir,
1402            crate_name,
1403            project_pascal,
1404            bundle_prefix,
1405            "bench_mobile::test_func",
1406        );
1407        assert!(
1408            result.is_ok(),
1409            "generate_ios_project failed: {:?}",
1410            result.err()
1411        );
1412
1413        // Verify project.yml was created
1414        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1415        assert!(project_yml_path.exists(), "project.yml should exist");
1416
1417        // Read and verify the bundle ID is correct (not duplicated)
1418        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1419
1420        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
1421        // NOT "dev.world.benchmobile.benchmobile"
1422        assert!(
1423            project_yml.contains("dev.world.benchmobile.BenchRunner"),
1424            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1425            project_yml
1426        );
1427        assert!(
1428            !project_yml.contains("dev.world.benchmobile.benchmobile"),
1429            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1430            project_yml
1431        );
1432
1433        // Cleanup
1434        fs::remove_dir_all(&temp_dir).ok();
1435    }
1436
1437    #[test]
1438    fn test_cross_platform_naming_consistency() {
1439        // Test that Android and iOS use the same naming convention for package/bundle IDs
1440        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1441        let _ = fs::remove_dir_all(&temp_dir);
1442        fs::create_dir_all(&temp_dir).unwrap();
1443
1444        let project_name = "bench-mobile";
1445
1446        // Generate Android project
1447        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1448        assert!(
1449            result.is_ok(),
1450            "generate_android_project failed: {:?}",
1451            result.err()
1452        );
1453
1454        // Generate iOS project (mimicking how ensure_ios_project does it)
1455        let bundle_id_component = sanitize_bundle_id_component(project_name);
1456        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1457        let result = generate_ios_project(
1458            &temp_dir,
1459            &project_name.replace('-', "_"),
1460            "BenchRunner",
1461            &bundle_prefix,
1462            "bench_mobile::test_func",
1463        );
1464        assert!(
1465            result.is_ok(),
1466            "generate_ios_project failed: {:?}",
1467            result.err()
1468        );
1469
1470        // Read Android build.gradle to extract package name
1471        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1472            .expect("Failed to read Android build.gradle");
1473
1474        // Read iOS project.yml to extract bundle ID prefix
1475        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1476            .expect("Failed to read iOS project.yml");
1477
1478        // Both should use "benchmobile" (without hyphens or underscores)
1479        // Android: namespace = "dev.world.benchmobile"
1480        // iOS: bundleIdPrefix: dev.world.benchmobile
1481        assert!(
1482            android_build_gradle.contains("dev.world.benchmobile"),
1483            "Android package should be 'dev.world.benchmobile', got:\n{}",
1484            android_build_gradle
1485        );
1486        assert!(
1487            ios_project_yml.contains("dev.world.benchmobile"),
1488            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1489            ios_project_yml
1490        );
1491
1492        // Ensure Android doesn't use hyphens or underscores in the package ID component
1493        assert!(
1494            !android_build_gradle.contains("dev.world.bench-mobile"),
1495            "Android package should NOT contain hyphens"
1496        );
1497        assert!(
1498            !android_build_gradle.contains("dev.world.bench_mobile"),
1499            "Android package should NOT contain underscores"
1500        );
1501
1502        // Cleanup
1503        fs::remove_dir_all(&temp_dir).ok();
1504    }
1505
1506    #[test]
1507    fn test_cross_platform_version_consistency() {
1508        // Test that Android and iOS use the same version strings
1509        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1510        let _ = fs::remove_dir_all(&temp_dir);
1511        fs::create_dir_all(&temp_dir).unwrap();
1512
1513        let project_name = "test-project";
1514
1515        // Generate Android project
1516        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1517        assert!(
1518            result.is_ok(),
1519            "generate_android_project failed: {:?}",
1520            result.err()
1521        );
1522
1523        // Generate iOS project
1524        let bundle_id_component = sanitize_bundle_id_component(project_name);
1525        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1526        let result = generate_ios_project(
1527            &temp_dir,
1528            &project_name.replace('-', "_"),
1529            "BenchRunner",
1530            &bundle_prefix,
1531            "test_project::test_func",
1532        );
1533        assert!(
1534            result.is_ok(),
1535            "generate_ios_project failed: {:?}",
1536            result.err()
1537        );
1538
1539        // Read Android build.gradle
1540        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1541            .expect("Failed to read Android build.gradle");
1542
1543        // Read iOS project.yml
1544        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1545            .expect("Failed to read iOS project.yml");
1546
1547        // Both should use version "1.0.0"
1548        assert!(
1549            android_build_gradle.contains("versionName \"1.0.0\""),
1550            "Android versionName should be '1.0.0', got:\n{}",
1551            android_build_gradle
1552        );
1553        assert!(
1554            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
1555            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
1556            ios_project_yml
1557        );
1558
1559        // Cleanup
1560        fs::remove_dir_all(&temp_dir).ok();
1561    }
1562
1563    #[test]
1564    fn test_bundle_id_prefix_consistency() {
1565        // Test that the bundle ID prefix format is consistent across platforms
1566        let test_cases = vec![
1567            ("my-project", "dev.world.myproject"),
1568            ("bench_mobile", "dev.world.benchmobile"),
1569            ("TestApp", "dev.world.testapp"),
1570            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
1571            (
1572                "app_with_many_underscores",
1573                "dev.world.appwithmanyunderscores",
1574            ),
1575        ];
1576
1577        for (input, expected_prefix) in test_cases {
1578            let sanitized = sanitize_bundle_id_component(input);
1579            let full_prefix = format!("dev.world.{}", sanitized);
1580            assert_eq!(
1581                full_prefix, expected_prefix,
1582                "For input '{}', expected '{}' but got '{}'",
1583                input, expected_prefix, full_prefix
1584            );
1585        }
1586    }
1587}