Skip to main content

mobench_sdk/
codegen.rs

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