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 && let Ok(text) = std::str::from_utf8(&contents) {
772                    let rendered = render_template(text, vars);
773                    // Validate that all template variables were replaced
774                    validate_no_unreplaced_placeholders(&rendered, &relative)?;
775                    contents = rendered.into_bytes();
776                }
777
778                let out_path = out_root.join(relative);
779                if let Some(parent) = out_path.parent() {
780                    fs::create_dir_all(parent)?;
781                }
782                fs::write(&out_path, contents)?;
783            }
784        }
785    }
786    Ok(())
787}
788
789/// Checks if a file should be processed for template variable substitution
790/// based on its extension
791fn is_template_file(path: &Path) -> bool {
792    // Check for .template extension on any file
793    if let Some(ext) = path.extension() {
794        if ext == "template" {
795            return true;
796        }
797        // Check if the base extension is in our list
798        if let Some(ext_str) = ext.to_str() {
799            return TEMPLATE_EXTENSIONS.contains(&ext_str);
800        }
801    }
802    // Also check the filename without the .template extension
803    if let Some(stem) = path.file_stem() {
804        let stem_path = Path::new(stem);
805        if let Some(ext) = stem_path.extension()
806            && let Some(ext_str) = ext.to_str()
807        {
808            return TEMPLATE_EXTENSIONS.contains(&ext_str);
809        }
810    }
811    false
812}
813
814/// Validates that no unreplaced template placeholders remain in the rendered content
815fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
816    // Find all {{...}} patterns
817    let mut pos = 0;
818    let mut unreplaced = Vec::new();
819
820    while let Some(start) = content[pos..].find("{{") {
821        let abs_start = pos + start;
822        if let Some(end) = content[abs_start..].find("}}") {
823            let placeholder = &content[abs_start..abs_start + end + 2];
824            // Extract just the variable name
825            let var_name = &content[abs_start + 2..abs_start + end];
826            // Skip placeholders that look like Gradle variable syntax (e.g., ${...})
827            // or other non-template patterns
828            if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
829                unreplaced.push(placeholder.to_string());
830            }
831            pos = abs_start + end + 2;
832        } else {
833            break;
834        }
835    }
836
837    if !unreplaced.is_empty() {
838        return Err(BenchError::Build(format!(
839            "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
840             This is a bug in mobench-sdk. Please report it at:\n\
841             https://github.com/worldcoin/mobile-bench-rs/issues",
842            file_path, unreplaced
843        )));
844    }
845
846    Ok(())
847}
848
849fn render_template(input: &str, vars: &[TemplateVar]) -> String {
850    let mut output = input.to_string();
851    for var in vars {
852        output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
853    }
854    output
855}
856
857/// Sanitizes a string to be a valid iOS bundle identifier component
858///
859/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9),
860/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency,
861/// this function converts all non-alphanumeric characters to lowercase letters only.
862///
863/// Examples:
864/// - "bench-mobile" -> "benchmobile"
865/// - "bench_mobile" -> "benchmobile"
866/// - "my-project_name" -> "myprojectname"
867pub fn sanitize_bundle_id_component(name: &str) -> String {
868    name.chars()
869        .filter(|c| c.is_ascii_alphanumeric())
870        .collect::<String>()
871        .to_lowercase()
872}
873
874fn sanitize_package_name(name: &str) -> String {
875    name.chars()
876        .map(|c| {
877            if c.is_ascii_alphanumeric() {
878                c.to_ascii_lowercase()
879            } else {
880                '-'
881            }
882        })
883        .collect::<String>()
884        .trim_matches('-')
885        .replace("--", "-")
886}
887
888/// Converts a string to PascalCase
889pub fn to_pascal_case(input: &str) -> String {
890    input
891        .split(|c: char| !c.is_ascii_alphanumeric())
892        .filter(|s| !s.is_empty())
893        .map(|s| {
894            let mut chars = s.chars();
895            let first = chars.next().unwrap().to_ascii_uppercase();
896            let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
897            format!("{}{}", first, rest)
898        })
899        .collect::<String>()
900}
901
902/// Checks if the Android project scaffolding exists at the given output directory
903///
904/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists.
905pub fn android_project_exists(output_dir: &Path) -> bool {
906    let android_dir = output_dir.join("android");
907    android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
908}
909
910/// Checks if the iOS project scaffolding exists at the given output directory
911///
912/// Returns true if the `ios/BenchRunner/project.yml` file exists.
913pub fn ios_project_exists(output_dir: &Path) -> bool {
914    output_dir.join("ios/BenchRunner/project.yml").exists()
915}
916
917/// Checks whether an existing iOS project was generated for the given library name.
918///
919/// Returns `false` if the xcframework reference in `project.yml` doesn't match,
920/// which means the project needs to be regenerated for the new crate.
921fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
922    let project_yml = output_dir.join("ios/BenchRunner/project.yml");
923    let Ok(content) = std::fs::read_to_string(&project_yml) else {
924        return false;
925    };
926    let expected = format!("../{}.xcframework", library_name);
927    content.contains(&expected)
928}
929
930/// Checks whether an existing Android project was generated for the given library name.
931///
932/// Returns `false` if the JNI library name in `build.gradle` doesn't match,
933/// which means the project needs to be regenerated for the new crate.
934fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
935    let build_gradle = output_dir.join("android/app/build.gradle");
936    let Ok(content) = std::fs::read_to_string(&build_gradle) else {
937        return false;
938    };
939    let expected = format!("lib{}.so", library_name);
940    content.contains(&expected)
941}
942
943/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
944///
945/// This function looks for functions marked with the `#[benchmark]` attribute and returns
946/// the first one found in the format `{crate_name}::{function_name}`.
947///
948/// # Arguments
949///
950/// * `crate_dir` - Path to the crate directory containing Cargo.toml
951/// * `crate_name` - Name of the crate (used as prefix for the function name)
952///
953/// # Returns
954///
955/// * `Some(String)` - The detected function name in format `crate_name::function_name`
956/// * `None` - If no benchmark functions are found or if the file cannot be read
957pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
958    let lib_rs = crate_dir.join("src/lib.rs");
959    if !lib_rs.exists() {
960        return None;
961    }
962
963    let file = fs::File::open(&lib_rs).ok()?;
964    let reader = BufReader::new(file);
965
966    let mut found_benchmark_attr = false;
967    let crate_name_normalized = crate_name.replace('-', "_");
968
969    for line in reader.lines().map_while(Result::ok) {
970        let trimmed = line.trim();
971
972        // Check for #[benchmark] attribute
973        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
974            found_benchmark_attr = true;
975            continue;
976        }
977
978        // If we found a benchmark attribute, look for the function definition
979        if found_benchmark_attr {
980            // Look for "fn function_name" or "pub fn function_name"
981            if let Some(fn_pos) = trimmed.find("fn ") {
982                let after_fn = &trimmed[fn_pos + 3..];
983                // Extract function name (until '(' or whitespace)
984                let fn_name: String = after_fn
985                    .chars()
986                    .take_while(|c| c.is_alphanumeric() || *c == '_')
987                    .collect();
988
989                if !fn_name.is_empty() {
990                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
991                }
992            }
993            // Reset if we hit a line that's not a function definition
994            // (could be another attribute or comment)
995            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
996                found_benchmark_attr = false;
997            }
998        }
999    }
1000
1001    None
1002}
1003
1004/// Detects all benchmark functions in a crate by scanning src/lib.rs for `#[benchmark]`
1005///
1006/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1007/// all found in the format `{crate_name}::{function_name}`.
1008///
1009/// # Arguments
1010///
1011/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1012/// * `crate_name` - Name of the crate (used as prefix for the function names)
1013///
1014/// # Returns
1015///
1016/// A vector of benchmark function names in format `crate_name::function_name`
1017pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1018    let lib_rs = crate_dir.join("src/lib.rs");
1019    if !lib_rs.exists() {
1020        return Vec::new();
1021    }
1022
1023    let Ok(file) = fs::File::open(&lib_rs) else {
1024        return Vec::new();
1025    };
1026    let reader = BufReader::new(file);
1027
1028    let mut benchmarks = Vec::new();
1029    let mut found_benchmark_attr = false;
1030    let crate_name_normalized = crate_name.replace('-', "_");
1031
1032    for line in reader.lines().map_while(Result::ok) {
1033        let trimmed = line.trim();
1034
1035        // Check for #[benchmark] attribute
1036        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1037            found_benchmark_attr = true;
1038            continue;
1039        }
1040
1041        // If we found a benchmark attribute, look for the function definition
1042        if found_benchmark_attr {
1043            // Look for "fn function_name" or "pub fn function_name"
1044            if let Some(fn_pos) = trimmed.find("fn ") {
1045                let after_fn = &trimmed[fn_pos + 3..];
1046                // Extract function name (until '(' or whitespace)
1047                let fn_name: String = after_fn
1048                    .chars()
1049                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1050                    .collect();
1051
1052                if !fn_name.is_empty() {
1053                    benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1054                }
1055                found_benchmark_attr = false;
1056            }
1057            // Reset if we hit a line that's not a function definition
1058            // (could be another attribute or comment)
1059            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1060                found_benchmark_attr = false;
1061            }
1062        }
1063    }
1064
1065    benchmarks
1066}
1067
1068/// Validates that a benchmark function exists in the crate source
1069///
1070/// # Arguments
1071///
1072/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1073/// * `crate_name` - Name of the crate (used as prefix for the function names)
1074/// * `function_name` - The function name to validate (with or without crate prefix)
1075///
1076/// # Returns
1077///
1078/// `true` if the function is found, `false` otherwise
1079pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1080    let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1081    let crate_name_normalized = crate_name.replace('-', "_");
1082
1083    // Normalize the function name - add crate prefix if missing
1084    let normalized_name = if function_name.contains("::") {
1085        function_name.to_string()
1086    } else {
1087        format!("{}::{}", crate_name_normalized, function_name)
1088    };
1089
1090    benchmarks.iter().any(|b| b == &normalized_name)
1091}
1092
1093/// Resolves the default benchmark function for a project
1094///
1095/// This function attempts to auto-detect benchmark functions from the crate's source.
1096/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
1097///
1098/// # Arguments
1099///
1100/// * `project_root` - Root directory of the project
1101/// * `crate_name` - Name of the benchmark crate
1102/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
1103///
1104/// # Returns
1105///
1106/// The default function name in format `crate_name::function_name`
1107pub fn resolve_default_function(
1108    project_root: &Path,
1109    crate_name: &str,
1110    crate_dir: Option<&Path>,
1111) -> String {
1112    let crate_name_normalized = crate_name.replace('-', "_");
1113
1114    // Try to find the crate directory
1115    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1116        vec![dir.to_path_buf()]
1117    } else {
1118        vec![
1119            project_root.join("bench-mobile"),
1120            project_root.join("crates").join(crate_name),
1121            project_root.to_path_buf(),
1122        ]
1123    };
1124
1125    // Try to detect benchmarks from each potential location
1126    for dir in &search_dirs {
1127        if dir.join("Cargo.toml").exists()
1128            && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1129        {
1130            return detected;
1131        }
1132    }
1133
1134    // Fallback: use a sensible default based on crate name
1135    format!("{}::example_benchmark", crate_name_normalized)
1136}
1137
1138/// Auto-generates Android project scaffolding from a crate name
1139///
1140/// This is a convenience function that derives template variables from the
1141/// crate name and generates the Android project structure. It auto-detects
1142/// the default benchmark function from the crate's source code.
1143///
1144/// # Arguments
1145///
1146/// * `output_dir` - Directory to write the `android/` project into
1147/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1148pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1149    ensure_android_project_with_options(output_dir, crate_name, None, None)
1150}
1151
1152/// Auto-generates Android project scaffolding with additional options
1153///
1154/// This is a more flexible version of `ensure_android_project` that allows
1155/// specifying a custom default function and/or crate directory.
1156///
1157/// # Arguments
1158///
1159/// * `output_dir` - Directory to write the `android/` project into
1160/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1161/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1162/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1163pub fn ensure_android_project_with_options(
1164    output_dir: &Path,
1165    crate_name: &str,
1166    project_root: Option<&Path>,
1167    crate_dir: Option<&Path>,
1168) -> Result<(), BenchError> {
1169    let library_name = crate_name.replace('-', "_");
1170    if android_project_exists(output_dir)
1171        && android_project_matches_library(output_dir, &library_name)
1172    {
1173        return Ok(());
1174    }
1175
1176    println!("Android project not found, generating scaffolding...");
1177    let project_slug = crate_name.replace('-', "_");
1178
1179    // Resolve the default function by auto-detecting from source
1180    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1181    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1182
1183    generate_android_project(output_dir, &project_slug, &default_function)?;
1184    println!(
1185        "  Generated Android project at {:?}",
1186        output_dir.join("android")
1187    );
1188    println!("  Default benchmark function: {}", default_function);
1189    Ok(())
1190}
1191
1192/// Auto-generates iOS project scaffolding from a crate name
1193///
1194/// This is a convenience function that derives template variables from the
1195/// crate name and generates the iOS project structure. It auto-detects
1196/// the default benchmark function from the crate's source code.
1197///
1198/// # Arguments
1199///
1200/// * `output_dir` - Directory to write the `ios/` project into
1201/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1202pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1203    ensure_ios_project_with_options(output_dir, crate_name, None, None)
1204}
1205
1206/// Auto-generates iOS project scaffolding with additional options
1207///
1208/// This is a more flexible version of `ensure_ios_project` that allows
1209/// specifying a custom default function and/or crate directory.
1210///
1211/// # Arguments
1212///
1213/// * `output_dir` - Directory to write the `ios/` project into
1214/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1215/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1216/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1217pub fn ensure_ios_project_with_options(
1218    output_dir: &Path,
1219    crate_name: &str,
1220    project_root: Option<&Path>,
1221    crate_dir: Option<&Path>,
1222) -> Result<(), BenchError> {
1223    let library_name = crate_name.replace('-', "_");
1224    let project_exists = ios_project_exists(output_dir);
1225    let project_matches = ios_project_matches_library(output_dir, &library_name);
1226    if project_exists && !project_matches {
1227        println!("Existing iOS scaffolding does not match library, regenerating...");
1228    } else if project_exists {
1229        println!("Refreshing generated iOS scaffolding...");
1230    } else {
1231        println!("iOS project not found, generating scaffolding...");
1232    }
1233
1234    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
1235    let project_pascal = "BenchRunner";
1236    // Derive library name and bundle prefix from crate name
1237    let library_name = crate_name.replace('-', "_");
1238    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
1239    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
1240    let bundle_id_component = sanitize_bundle_id_component(crate_name);
1241    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1242
1243    // Resolve the default function by auto-detecting from source
1244    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1245    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1246
1247    generate_ios_project(
1248        output_dir,
1249        &library_name,
1250        project_pascal,
1251        &bundle_prefix,
1252        &default_function,
1253    )?;
1254    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
1255    println!("  Default benchmark function: {}", default_function);
1256    Ok(())
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262    use std::env;
1263
1264    #[test]
1265    fn test_generate_bench_mobile_crate() {
1266        let temp_dir = env::temp_dir().join("mobench-sdk-test");
1267        fs::create_dir_all(&temp_dir).unwrap();
1268
1269        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1270        assert!(result.is_ok());
1271
1272        // Verify files were created
1273        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1274        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1275        assert!(temp_dir.join("bench-mobile/build.rs").exists());
1276
1277        // Cleanup
1278        fs::remove_dir_all(&temp_dir).ok();
1279    }
1280
1281    #[test]
1282    fn test_generate_android_project_no_unreplaced_placeholders() {
1283        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1284        // Clean up any previous test run
1285        let _ = fs::remove_dir_all(&temp_dir);
1286        fs::create_dir_all(&temp_dir).unwrap();
1287
1288        let result =
1289            generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1290        assert!(
1291            result.is_ok(),
1292            "generate_android_project failed: {:?}",
1293            result.err()
1294        );
1295
1296        // Verify key files exist
1297        let android_dir = temp_dir.join("android");
1298        assert!(android_dir.join("settings.gradle").exists());
1299        assert!(android_dir.join("app/build.gradle").exists());
1300        assert!(
1301            android_dir
1302                .join("app/src/main/AndroidManifest.xml")
1303                .exists()
1304        );
1305        assert!(
1306            android_dir
1307                .join("app/src/main/res/values/strings.xml")
1308                .exists()
1309        );
1310        assert!(
1311            android_dir
1312                .join("app/src/main/res/values/themes.xml")
1313                .exists()
1314        );
1315
1316        // Verify no unreplaced placeholders remain in generated files
1317        let files_to_check = [
1318            "settings.gradle",
1319            "app/build.gradle",
1320            "app/src/main/AndroidManifest.xml",
1321            "app/src/main/res/values/strings.xml",
1322            "app/src/main/res/values/themes.xml",
1323        ];
1324
1325        for file in files_to_check {
1326            let path = android_dir.join(file);
1327            let contents =
1328                fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1329
1330            // Check for unreplaced placeholders
1331            let has_placeholder = contents.contains("{{") && contents.contains("}}");
1332            assert!(
1333                !has_placeholder,
1334                "File {} contains unreplaced template placeholders: {}",
1335                file, contents
1336            );
1337        }
1338
1339        // Verify specific substitutions were made
1340        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1341        assert!(
1342            settings.contains("my-bench-project-android")
1343                || settings.contains("my_bench_project-android"),
1344            "settings.gradle should contain project name"
1345        );
1346
1347        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1348        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
1349        assert!(
1350            build_gradle.contains("dev.world.mybenchproject"),
1351            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1352        );
1353        assert!(
1354            !build_gradle.contains("testBuildType \"release\""),
1355            "debug builds should be able to produce assembleDebugAndroidTest"
1356        );
1357        assert!(
1358            build_gradle.contains("mobenchTestBuildType"),
1359            "release builds should be able to request assembleReleaseAndroidTest"
1360        );
1361
1362        let manifest =
1363            fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1364        assert!(
1365            manifest.contains("Theme.MyBenchProject"),
1366            "AndroidManifest.xml should contain PascalCase theme name"
1367        );
1368
1369        let strings =
1370            fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1371        assert!(
1372            strings.contains("Benchmark"),
1373            "strings.xml should contain app name with Benchmark"
1374        );
1375
1376        // Verify Kotlin files are in the correct package directory structure
1377        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
1378        let main_activity_path =
1379            android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1380        assert!(
1381            main_activity_path.exists(),
1382            "MainActivity.kt should be in package directory: {:?}",
1383            main_activity_path
1384        );
1385
1386        let test_activity_path = android_dir
1387            .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1388        assert!(
1389            test_activity_path.exists(),
1390            "MainActivityTest.kt should be in package directory: {:?}",
1391            test_activity_path
1392        );
1393
1394        // Verify the files are NOT in the root java directory
1395        assert!(
1396            !android_dir
1397                .join("app/src/main/java/MainActivity.kt")
1398                .exists(),
1399            "MainActivity.kt should not be in root java directory"
1400        );
1401        assert!(
1402            !android_dir
1403                .join("app/src/androidTest/java/MainActivityTest.kt")
1404                .exists(),
1405            "MainActivityTest.kt should not be in root java directory"
1406        );
1407
1408        // Cleanup
1409        fs::remove_dir_all(&temp_dir).ok();
1410    }
1411
1412    #[test]
1413    fn test_generate_android_project_replaces_previous_package_tree() {
1414        let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1415        let _ = fs::remove_dir_all(&temp_dir);
1416        fs::create_dir_all(&temp_dir).unwrap();
1417
1418        generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1419            .unwrap();
1420        let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1421        assert!(
1422            old_package_dir.exists(),
1423            "expected first package tree to exist"
1424        );
1425
1426        generate_android_project(
1427            &temp_dir,
1428            "basic_benchmark",
1429            "basic_benchmark::bench_fibonacci",
1430        )
1431        .unwrap();
1432
1433        let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1434        assert!(
1435            new_package_dir.exists(),
1436            "expected new package tree to exist"
1437        );
1438        assert!(
1439            !old_package_dir.exists(),
1440            "old package tree should be removed when regenerating the Android scaffold"
1441        );
1442
1443        fs::remove_dir_all(&temp_dir).ok();
1444    }
1445
1446    #[test]
1447    fn test_is_template_file() {
1448        assert!(is_template_file(Path::new("settings.gradle")));
1449        assert!(is_template_file(Path::new("app/build.gradle")));
1450        assert!(is_template_file(Path::new("AndroidManifest.xml")));
1451        assert!(is_template_file(Path::new("strings.xml")));
1452        assert!(is_template_file(Path::new("MainActivity.kt.template")));
1453        assert!(is_template_file(Path::new("project.yml")));
1454        assert!(is_template_file(Path::new("Info.plist")));
1455        assert!(!is_template_file(Path::new("libfoo.so")));
1456        assert!(!is_template_file(Path::new("image.png")));
1457    }
1458
1459    #[test]
1460    fn test_mobile_templates_read_process_peak_memory_compatibly() {
1461        let android =
1462            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1463        assert!(
1464            !android.contains("sample.processPeakMemoryKb"),
1465            "Android template should not require generated bindings to expose processPeakMemoryKb"
1466        );
1467        assert!(
1468            !android.contains("it.processPeakMemoryKb"),
1469            "Android template should not require generated bindings to expose processPeakMemoryKb"
1470        );
1471        assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1472        assert!(
1473            !android.contains("sample.cpuTimeMs"),
1474            "Android template should tolerate BenchSample without cpuTimeMs"
1475        );
1476        assert!(
1477            !android.contains("sample.peakMemoryKb"),
1478            "Android template should tolerate BenchSample without peakMemoryKb"
1479        );
1480        assert!(
1481            !android.contains("report.phases"),
1482            "Android template should tolerate BenchReport without phases"
1483        );
1484        assert!(android.contains("ProcessMemorySampler"));
1485        assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1486        assert!(android.contains("/proc/self/smaps_rollup"));
1487        assert!(android.contains("class BenchmarkWorkerService : Service()"));
1488        assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1489        assert!(android.contains("startForegroundService(intent)"));
1490        assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1491        assert!(android.contains("fun isBenchmarkComplete()"));
1492        assert!(!android.contains("resultLatch.await"));
1493        assert!(android.contains("memory_process\", \"isolated_worker\""));
1494
1495        let android_test = include_str!(
1496            "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1497        );
1498        assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1499        assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1500        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1501        assert!(android_test.contains("activity.isBenchmarkComplete()"));
1502
1503        let ios_test = include_str!(
1504            "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1505        );
1506        assert!(
1507            ios_test.contains("\\\"error\\\""),
1508            "iOS XCUITest template should fail when the benchmark report is an error payload"
1509        );
1510
1511        let android_manifest =
1512            include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1513        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1514        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1515        assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1516        assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1517        assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1518
1519        let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1520        assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1521        assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1522
1523        let ios =
1524            include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1525        assert!(
1526            !ios.contains("sample.processPeakMemoryKb"),
1527            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1528        );
1529        assert!(
1530            !ios.contains(r"\.processPeakMemoryKb"),
1531            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1532        );
1533        assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1534        assert!(ios.contains("return [\n                \"name\": name,"));
1535        assert!(
1536            !ios.contains("sample.cpuTimeMs"),
1537            "iOS template should tolerate BenchSample without cpuTimeMs"
1538        );
1539        assert!(
1540            !ios.contains("sample.peakMemoryKb"),
1541            "iOS template should tolerate BenchSample without peakMemoryKb"
1542        );
1543        assert!(
1544            !ios.contains("report.phases"),
1545            "iOS template should tolerate BenchReport without phases"
1546        );
1547        assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1548        assert!(ios.contains("ProcessMemorySampler"));
1549        assert!(ios.contains("currentProcessResidentMemoryKb"));
1550        assert!(ios.contains("task_info("));
1551        assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1552        assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1553        assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1554    }
1555
1556    #[test]
1557    fn test_validate_no_unreplaced_placeholders() {
1558        // Should pass with no placeholders
1559        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1560
1561        // Should pass with Gradle variables (not our placeholders)
1562        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1563
1564        // Should fail with unreplaced template placeholders
1565        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1566        assert!(result.is_err());
1567        let err = result.unwrap_err().to_string();
1568        assert!(err.contains("{{NAME}}"));
1569    }
1570
1571    #[test]
1572    fn test_to_pascal_case() {
1573        assert_eq!(to_pascal_case("my-project"), "MyProject");
1574        assert_eq!(to_pascal_case("my_project"), "MyProject");
1575        assert_eq!(to_pascal_case("myproject"), "Myproject");
1576        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1577    }
1578
1579    #[test]
1580    fn test_detect_default_function_finds_benchmark() {
1581        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1582        let _ = fs::remove_dir_all(&temp_dir);
1583        fs::create_dir_all(temp_dir.join("src")).unwrap();
1584
1585        // Create a lib.rs with a benchmark function
1586        let lib_content = r#"
1587use mobench_sdk::benchmark;
1588
1589/// Some docs
1590#[benchmark]
1591fn my_benchmark_func() {
1592    // benchmark code
1593}
1594
1595fn helper_func() {}
1596"#;
1597        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1598        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1599
1600        let result = detect_default_function(&temp_dir, "my_crate");
1601        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1602
1603        // Cleanup
1604        fs::remove_dir_all(&temp_dir).ok();
1605    }
1606
1607    #[test]
1608    fn test_detect_default_function_no_benchmark() {
1609        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1610        let _ = fs::remove_dir_all(&temp_dir);
1611        fs::create_dir_all(temp_dir.join("src")).unwrap();
1612
1613        // Create a lib.rs without benchmark functions
1614        let lib_content = r#"
1615fn regular_function() {
1616    // no benchmark here
1617}
1618"#;
1619        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1620
1621        let result = detect_default_function(&temp_dir, "my_crate");
1622        assert!(result.is_none());
1623
1624        // Cleanup
1625        fs::remove_dir_all(&temp_dir).ok();
1626    }
1627
1628    #[test]
1629    fn test_detect_default_function_pub_fn() {
1630        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1631        let _ = fs::remove_dir_all(&temp_dir);
1632        fs::create_dir_all(temp_dir.join("src")).unwrap();
1633
1634        // Create a lib.rs with a public benchmark function
1635        let lib_content = r#"
1636#[benchmark]
1637pub fn public_bench() {
1638    // benchmark code
1639}
1640"#;
1641        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1642
1643        let result = detect_default_function(&temp_dir, "test-crate");
1644        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1645
1646        // Cleanup
1647        fs::remove_dir_all(&temp_dir).ok();
1648    }
1649
1650    #[test]
1651    fn test_resolve_default_function_fallback() {
1652        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1653        let _ = fs::remove_dir_all(&temp_dir);
1654        fs::create_dir_all(&temp_dir).unwrap();
1655
1656        // No lib.rs exists, should fall back to default
1657        let result = resolve_default_function(&temp_dir, "my-crate", None);
1658        assert_eq!(result, "my_crate::example_benchmark");
1659
1660        // Cleanup
1661        fs::remove_dir_all(&temp_dir).ok();
1662    }
1663
1664    #[test]
1665    fn test_sanitize_bundle_id_component() {
1666        // Hyphens should be removed
1667        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1668        // Underscores should be removed
1669        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1670        // Mixed separators should all be removed
1671        assert_eq!(
1672            sanitize_bundle_id_component("my-project_name"),
1673            "myprojectname"
1674        );
1675        // Already valid should remain unchanged (but lowercase)
1676        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1677        // Numbers should be preserved
1678        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1679        // Uppercase should be lowercased
1680        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1681        // Complex case
1682        assert_eq!(
1683            sanitize_bundle_id_component("My-Complex_Project-123"),
1684            "mycomplexproject123"
1685        );
1686    }
1687
1688    #[test]
1689    fn test_generate_ios_project_bundle_id_not_duplicated() {
1690        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1691        // Clean up any previous test run
1692        let _ = fs::remove_dir_all(&temp_dir);
1693        fs::create_dir_all(&temp_dir).unwrap();
1694
1695        // Use a crate name that would previously cause duplication
1696        let crate_name = "bench-mobile";
1697        let bundle_prefix = "dev.world.benchmobile";
1698        let project_pascal = "BenchRunner";
1699
1700        let result = generate_ios_project(
1701            &temp_dir,
1702            crate_name,
1703            project_pascal,
1704            bundle_prefix,
1705            "bench_mobile::test_func",
1706        );
1707        assert!(
1708            result.is_ok(),
1709            "generate_ios_project failed: {:?}",
1710            result.err()
1711        );
1712
1713        // Verify project.yml was created
1714        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1715        assert!(project_yml_path.exists(), "project.yml should exist");
1716
1717        // Read and verify the bundle ID is correct (not duplicated)
1718        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1719
1720        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
1721        // NOT "dev.world.benchmobile.benchmobile"
1722        assert!(
1723            project_yml.contains("dev.world.benchmobile.BenchRunner"),
1724            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1725            project_yml
1726        );
1727        assert!(
1728            !project_yml.contains("dev.world.benchmobile.benchmobile"),
1729            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1730            project_yml
1731        );
1732        assert!(
1733            project_yml.contains("embed: false"),
1734            "Static xcframework dependency should be link-only, got:\n{}",
1735            project_yml
1736        );
1737
1738        // Cleanup
1739        fs::remove_dir_all(&temp_dir).ok();
1740    }
1741
1742    #[test]
1743    fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1744        let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1745        let _ = fs::remove_dir_all(&temp_dir);
1746        fs::create_dir_all(&temp_dir).unwrap();
1747
1748        generate_ios_project(
1749            &temp_dir,
1750            "bench_mobile",
1751            "BenchRunner",
1752            "dev.world.benchmobile",
1753            "bench_mobile::bench_prepare",
1754        )
1755        .unwrap();
1756
1757        let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1758        fs::create_dir_all(resources_dir.join("nested")).unwrap();
1759        fs::write(
1760            resources_dir.join("bench_spec.json"),
1761            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1762        )
1763        .unwrap();
1764        fs::write(
1765            resources_dir.join("bench_meta.json"),
1766            r#"{"build_id":"build-123"}"#,
1767        )
1768        .unwrap();
1769        fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1770
1771        generate_ios_project(
1772            &temp_dir,
1773            "bench_mobile",
1774            "BenchRunner",
1775            "dev.world.benchmobile",
1776            "bench_mobile::bench_prepare",
1777        )
1778        .unwrap();
1779
1780        assert_eq!(
1781            fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1782            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1783        );
1784        assert_eq!(
1785            fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1786            r#"{"build_id":"build-123"}"#
1787        );
1788        assert_eq!(
1789            fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1790            "keep me"
1791        );
1792
1793        fs::remove_dir_all(&temp_dir).ok();
1794    }
1795
1796    #[test]
1797    fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1798        let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1799        let _ = fs::remove_dir_all(&temp_dir);
1800        fs::create_dir_all(&temp_dir).unwrap();
1801
1802        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1803            .expect("initial iOS project generation should succeed");
1804
1805        let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1806        assert!(content_view_path.exists(), "ContentView.swift should exist");
1807
1808        fs::write(&content_view_path, "stale generated content").unwrap();
1809
1810        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1811            .expect("refreshing existing iOS project should succeed");
1812
1813        let refreshed = fs::read_to_string(&content_view_path).unwrap();
1814        assert!(
1815            refreshed.contains("ProfileLaunchOptions"),
1816            "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1817            refreshed
1818        );
1819        assert!(
1820            refreshed.contains("repeatUntilMs"),
1821            "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1822            refreshed
1823        );
1824        assert!(
1825            refreshed.contains("Task.detached(priority: .userInitiated)"),
1826            "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
1827            refreshed
1828        );
1829        assert!(
1830            refreshed.contains("await MainActor.run"),
1831            "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
1832            refreshed
1833        );
1834
1835        fs::remove_dir_all(&temp_dir).ok();
1836    }
1837
1838    #[test]
1839    fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
1840        let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
1841        let _ = fs::remove_dir_all(&temp_dir);
1842        fs::create_dir_all(&temp_dir).unwrap();
1843
1844        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1845            .expect("initial iOS project generation should succeed");
1846
1847        let ui_test_path =
1848            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1849        assert!(
1850            ui_test_path.exists(),
1851            "BenchRunnerUITests.swift should exist"
1852        );
1853
1854        fs::write(&ui_test_path, "stale generated content").unwrap();
1855
1856        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1857            .expect("refreshing existing iOS project should succeed");
1858
1859        let refreshed = fs::read_to_string(&ui_test_path).unwrap();
1860        assert!(
1861            refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
1862            "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
1863            refreshed
1864        );
1865        assert!(
1866            refreshed.contains(
1867                "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
1868            ),
1869            "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
1870            refreshed
1871        );
1872
1873        fs::remove_dir_all(&temp_dir).ok();
1874    }
1875
1876    #[test]
1877    fn test_generate_ios_project_uses_configured_benchmark_timeout() {
1878        let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1879        let _ = fs::remove_dir_all(&temp_dir);
1880        fs::create_dir_all(&temp_dir).unwrap();
1881
1882        let result = generate_ios_project_with_timeout(
1883            &temp_dir,
1884            "sample_fns",
1885            "BenchRunner",
1886            "dev.world.samplefns",
1887            "sample_fns::example_benchmark",
1888            1200,
1889        );
1890
1891        assert!(result.is_ok(), "generate_ios_project should succeed");
1892
1893        let ui_test_path =
1894            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1895        let contents = fs::read_to_string(&ui_test_path).unwrap();
1896        assert!(
1897            contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
1898            "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
1899            contents
1900        );
1901
1902        fs::remove_dir_all(&temp_dir).ok();
1903    }
1904
1905    #[test]
1906    fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
1907        assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
1908        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
1909        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
1910        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
1911    }
1912
1913    #[test]
1914    fn test_cross_platform_naming_consistency() {
1915        // Test that Android and iOS use the same naming convention for package/bundle IDs
1916        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1917        let _ = fs::remove_dir_all(&temp_dir);
1918        fs::create_dir_all(&temp_dir).unwrap();
1919
1920        let project_name = "bench-mobile";
1921
1922        // Generate Android project
1923        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1924        assert!(
1925            result.is_ok(),
1926            "generate_android_project failed: {:?}",
1927            result.err()
1928        );
1929
1930        // Generate iOS project (mimicking how ensure_ios_project does it)
1931        let bundle_id_component = sanitize_bundle_id_component(project_name);
1932        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1933        let result = generate_ios_project(
1934            &temp_dir,
1935            &project_name.replace('-', "_"),
1936            "BenchRunner",
1937            &bundle_prefix,
1938            "bench_mobile::test_func",
1939        );
1940        assert!(
1941            result.is_ok(),
1942            "generate_ios_project failed: {:?}",
1943            result.err()
1944        );
1945
1946        // Read Android build.gradle to extract package name
1947        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1948            .expect("Failed to read Android build.gradle");
1949
1950        // Read iOS project.yml to extract bundle ID prefix
1951        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1952            .expect("Failed to read iOS project.yml");
1953
1954        // Both should use "benchmobile" (without hyphens or underscores)
1955        // Android: namespace = "dev.world.benchmobile"
1956        // iOS: bundleIdPrefix: dev.world.benchmobile
1957        assert!(
1958            android_build_gradle.contains("dev.world.benchmobile"),
1959            "Android package should be 'dev.world.benchmobile', got:\n{}",
1960            android_build_gradle
1961        );
1962        assert!(
1963            ios_project_yml.contains("dev.world.benchmobile"),
1964            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1965            ios_project_yml
1966        );
1967
1968        // Ensure Android doesn't use hyphens or underscores in the package ID component
1969        assert!(
1970            !android_build_gradle.contains("dev.world.bench-mobile"),
1971            "Android package should NOT contain hyphens"
1972        );
1973        assert!(
1974            !android_build_gradle.contains("dev.world.bench_mobile"),
1975            "Android package should NOT contain underscores"
1976        );
1977
1978        // Cleanup
1979        fs::remove_dir_all(&temp_dir).ok();
1980    }
1981
1982    #[test]
1983    fn test_cross_platform_version_consistency() {
1984        // Test that Android and iOS use the same version strings
1985        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1986        let _ = fs::remove_dir_all(&temp_dir);
1987        fs::create_dir_all(&temp_dir).unwrap();
1988
1989        let project_name = "test-project";
1990
1991        // Generate Android project
1992        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
1993        assert!(
1994            result.is_ok(),
1995            "generate_android_project failed: {:?}",
1996            result.err()
1997        );
1998
1999        // Generate iOS project
2000        let bundle_id_component = sanitize_bundle_id_component(project_name);
2001        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2002        let result = generate_ios_project(
2003            &temp_dir,
2004            &project_name.replace('-', "_"),
2005            "BenchRunner",
2006            &bundle_prefix,
2007            "test_project::test_func",
2008        );
2009        assert!(
2010            result.is_ok(),
2011            "generate_ios_project failed: {:?}",
2012            result.err()
2013        );
2014
2015        // Read Android build.gradle
2016        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2017            .expect("Failed to read Android build.gradle");
2018
2019        // Read iOS project.yml
2020        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2021            .expect("Failed to read iOS project.yml");
2022
2023        // Both should use version "1.0.0"
2024        assert!(
2025            android_build_gradle.contains("versionName \"1.0.0\""),
2026            "Android versionName should be '1.0.0', got:\n{}",
2027            android_build_gradle
2028        );
2029        assert!(
2030            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2031            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2032            ios_project_yml
2033        );
2034
2035        // Cleanup
2036        fs::remove_dir_all(&temp_dir).ok();
2037    }
2038
2039    #[test]
2040    fn test_bundle_id_prefix_consistency() {
2041        // Test that the bundle ID prefix format is consistent across platforms
2042        let test_cases = vec![
2043            ("my-project", "dev.world.myproject"),
2044            ("bench_mobile", "dev.world.benchmobile"),
2045            ("TestApp", "dev.world.testapp"),
2046            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2047            (
2048                "app_with_many_underscores",
2049                "dev.world.appwithmanyunderscores",
2050            ),
2051        ];
2052
2053        for (input, expected_prefix) in test_cases {
2054            let sanitized = sanitize_bundle_id_component(input);
2055            let full_prefix = format!("dev.world.{}", sanitized);
2056            assert_eq!(
2057                full_prefix, expected_prefix,
2058                "For input '{}', expected '{}' but got '{}'",
2059                input, expected_prefix, full_prefix
2060            );
2061        }
2062    }
2063}