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 = "..", default-features = false, features = ["registry"] }}
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        let cargo_toml =
1277            fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
1278        assert!(
1279            cargo_toml.contains(
1280                r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
1281            ),
1282            "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
1283        );
1284
1285        // Cleanup
1286        fs::remove_dir_all(&temp_dir).ok();
1287    }
1288
1289    #[test]
1290    fn test_generate_android_project_no_unreplaced_placeholders() {
1291        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1292        // Clean up any previous test run
1293        let _ = fs::remove_dir_all(&temp_dir);
1294        fs::create_dir_all(&temp_dir).unwrap();
1295
1296        let result =
1297            generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1298        assert!(
1299            result.is_ok(),
1300            "generate_android_project failed: {:?}",
1301            result.err()
1302        );
1303
1304        // Verify key files exist
1305        let android_dir = temp_dir.join("android");
1306        assert!(android_dir.join("settings.gradle").exists());
1307        assert!(android_dir.join("app/build.gradle").exists());
1308        assert!(
1309            android_dir
1310                .join("app/src/main/AndroidManifest.xml")
1311                .exists()
1312        );
1313        assert!(
1314            android_dir
1315                .join("app/src/main/res/values/strings.xml")
1316                .exists()
1317        );
1318        assert!(
1319            android_dir
1320                .join("app/src/main/res/values/themes.xml")
1321                .exists()
1322        );
1323
1324        // Verify no unreplaced placeholders remain in generated files
1325        let files_to_check = [
1326            "settings.gradle",
1327            "app/build.gradle",
1328            "app/src/main/AndroidManifest.xml",
1329            "app/src/main/res/values/strings.xml",
1330            "app/src/main/res/values/themes.xml",
1331        ];
1332
1333        for file in files_to_check {
1334            let path = android_dir.join(file);
1335            let contents =
1336                fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1337
1338            // Check for unreplaced placeholders
1339            let has_placeholder = contents.contains("{{") && contents.contains("}}");
1340            assert!(
1341                !has_placeholder,
1342                "File {} contains unreplaced template placeholders: {}",
1343                file, contents
1344            );
1345        }
1346
1347        // Verify specific substitutions were made
1348        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1349        assert!(
1350            settings.contains("my-bench-project-android")
1351                || settings.contains("my_bench_project-android"),
1352            "settings.gradle should contain project name"
1353        );
1354
1355        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1356        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
1357        assert!(
1358            build_gradle.contains("dev.world.mybenchproject"),
1359            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1360        );
1361        assert!(
1362            !build_gradle.contains("testBuildType \"release\""),
1363            "debug builds should be able to produce assembleDebugAndroidTest"
1364        );
1365        assert!(
1366            build_gradle.contains("mobenchTestBuildType"),
1367            "release builds should be able to request assembleReleaseAndroidTest"
1368        );
1369
1370        let manifest =
1371            fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1372        assert!(
1373            manifest.contains("Theme.MyBenchProject"),
1374            "AndroidManifest.xml should contain PascalCase theme name"
1375        );
1376
1377        let strings =
1378            fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1379        assert!(
1380            strings.contains("Benchmark"),
1381            "strings.xml should contain app name with Benchmark"
1382        );
1383
1384        // Verify Kotlin files are in the correct package directory structure
1385        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
1386        let main_activity_path =
1387            android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1388        assert!(
1389            main_activity_path.exists(),
1390            "MainActivity.kt should be in package directory: {:?}",
1391            main_activity_path
1392        );
1393
1394        let test_activity_path = android_dir
1395            .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1396        assert!(
1397            test_activity_path.exists(),
1398            "MainActivityTest.kt should be in package directory: {:?}",
1399            test_activity_path
1400        );
1401
1402        // Verify the files are NOT in the root java directory
1403        assert!(
1404            !android_dir
1405                .join("app/src/main/java/MainActivity.kt")
1406                .exists(),
1407            "MainActivity.kt should not be in root java directory"
1408        );
1409        assert!(
1410            !android_dir
1411                .join("app/src/androidTest/java/MainActivityTest.kt")
1412                .exists(),
1413            "MainActivityTest.kt should not be in root java directory"
1414        );
1415
1416        // Cleanup
1417        fs::remove_dir_all(&temp_dir).ok();
1418    }
1419
1420    #[test]
1421    fn test_generate_android_project_replaces_previous_package_tree() {
1422        let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1423        let _ = fs::remove_dir_all(&temp_dir);
1424        fs::create_dir_all(&temp_dir).unwrap();
1425
1426        generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1427            .unwrap();
1428        let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1429        assert!(
1430            old_package_dir.exists(),
1431            "expected first package tree to exist"
1432        );
1433
1434        generate_android_project(
1435            &temp_dir,
1436            "basic_benchmark",
1437            "basic_benchmark::bench_fibonacci",
1438        )
1439        .unwrap();
1440
1441        let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1442        assert!(
1443            new_package_dir.exists(),
1444            "expected new package tree to exist"
1445        );
1446        assert!(
1447            !old_package_dir.exists(),
1448            "old package tree should be removed when regenerating the Android scaffold"
1449        );
1450
1451        fs::remove_dir_all(&temp_dir).ok();
1452    }
1453
1454    #[test]
1455    fn test_is_template_file() {
1456        assert!(is_template_file(Path::new("settings.gradle")));
1457        assert!(is_template_file(Path::new("app/build.gradle")));
1458        assert!(is_template_file(Path::new("AndroidManifest.xml")));
1459        assert!(is_template_file(Path::new("strings.xml")));
1460        assert!(is_template_file(Path::new("MainActivity.kt.template")));
1461        assert!(is_template_file(Path::new("project.yml")));
1462        assert!(is_template_file(Path::new("Info.plist")));
1463        assert!(!is_template_file(Path::new("libfoo.so")));
1464        assert!(!is_template_file(Path::new("image.png")));
1465    }
1466
1467    #[test]
1468    fn test_mobile_templates_read_process_peak_memory_compatibly() {
1469        let android =
1470            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1471        assert!(
1472            !android.contains("sample.processPeakMemoryKb"),
1473            "Android template should not require generated bindings to expose processPeakMemoryKb"
1474        );
1475        assert!(
1476            !android.contains("it.processPeakMemoryKb"),
1477            "Android template should not require generated bindings to expose processPeakMemoryKb"
1478        );
1479        assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1480        assert!(
1481            !android.contains("sample.cpuTimeMs"),
1482            "Android template should tolerate BenchSample without cpuTimeMs"
1483        );
1484        assert!(
1485            !android.contains("sample.peakMemoryKb"),
1486            "Android template should tolerate BenchSample without peakMemoryKb"
1487        );
1488        assert!(
1489            !android.contains("report.phases"),
1490            "Android template should tolerate BenchReport without phases"
1491        );
1492        assert!(android.contains("ProcessMemorySampler"));
1493        assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1494        assert!(android.contains("/proc/self/smaps_rollup"));
1495        assert!(android.contains("class BenchmarkWorkerService : Service()"));
1496        assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1497        assert!(android.contains("startForegroundService(intent)"));
1498        assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1499        assert!(android.contains("fun isBenchmarkComplete()"));
1500        assert!(!android.contains("resultLatch.await"));
1501        assert!(android.contains("memory_process\", \"isolated_worker\""));
1502
1503        let android_test = include_str!(
1504            "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1505        );
1506        assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1507        assert!(android_test.contains("Thread.sleep(heartbeatMs)"));
1508        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1509        assert!(android_test.contains("activity.isBenchmarkComplete()"));
1510
1511        let ios_test = include_str!(
1512            "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1513        );
1514        assert!(
1515            ios_test.contains("\\\"error\\\""),
1516            "iOS XCUITest template should fail when the benchmark report is an error payload"
1517        );
1518
1519        let android_manifest =
1520            include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1521        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1522        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1523        assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1524        assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1525        assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1526
1527        let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1528        assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1529        assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1530
1531        let ios =
1532            include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1533        assert!(
1534            !ios.contains("sample.processPeakMemoryKb"),
1535            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1536        );
1537        assert!(
1538            !ios.contains(r"\.processPeakMemoryKb"),
1539            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1540        );
1541        assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1542        assert!(ios.contains("return [\n                \"name\": name,"));
1543        assert!(
1544            !ios.contains("sample.cpuTimeMs"),
1545            "iOS template should tolerate BenchSample without cpuTimeMs"
1546        );
1547        assert!(
1548            !ios.contains("sample.peakMemoryKb"),
1549            "iOS template should tolerate BenchSample without peakMemoryKb"
1550        );
1551        assert!(
1552            !ios.contains("report.phases"),
1553            "iOS template should tolerate BenchReport without phases"
1554        );
1555        assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1556        assert!(ios.contains("ProcessMemorySampler"));
1557        assert!(ios.contains("currentProcessResidentMemoryKb"));
1558        assert!(ios.contains("task_info("));
1559        assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1560        assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1561        assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1562    }
1563
1564    #[test]
1565    fn test_validate_no_unreplaced_placeholders() {
1566        // Should pass with no placeholders
1567        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1568
1569        // Should pass with Gradle variables (not our placeholders)
1570        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1571
1572        // Should fail with unreplaced template placeholders
1573        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1574        assert!(result.is_err());
1575        let err = result.unwrap_err().to_string();
1576        assert!(err.contains("{{NAME}}"));
1577    }
1578
1579    #[test]
1580    fn test_to_pascal_case() {
1581        assert_eq!(to_pascal_case("my-project"), "MyProject");
1582        assert_eq!(to_pascal_case("my_project"), "MyProject");
1583        assert_eq!(to_pascal_case("myproject"), "Myproject");
1584        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1585    }
1586
1587    #[test]
1588    fn test_detect_default_function_finds_benchmark() {
1589        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1590        let _ = fs::remove_dir_all(&temp_dir);
1591        fs::create_dir_all(temp_dir.join("src")).unwrap();
1592
1593        // Create a lib.rs with a benchmark function
1594        let lib_content = r#"
1595use mobench_sdk::benchmark;
1596
1597/// Some docs
1598#[benchmark]
1599fn my_benchmark_func() {
1600    // benchmark code
1601}
1602
1603fn helper_func() {}
1604"#;
1605        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1606        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1607
1608        let result = detect_default_function(&temp_dir, "my_crate");
1609        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1610
1611        // Cleanup
1612        fs::remove_dir_all(&temp_dir).ok();
1613    }
1614
1615    #[test]
1616    fn test_detect_default_function_no_benchmark() {
1617        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1618        let _ = fs::remove_dir_all(&temp_dir);
1619        fs::create_dir_all(temp_dir.join("src")).unwrap();
1620
1621        // Create a lib.rs without benchmark functions
1622        let lib_content = r#"
1623fn regular_function() {
1624    // no benchmark here
1625}
1626"#;
1627        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1628
1629        let result = detect_default_function(&temp_dir, "my_crate");
1630        assert!(result.is_none());
1631
1632        // Cleanup
1633        fs::remove_dir_all(&temp_dir).ok();
1634    }
1635
1636    #[test]
1637    fn test_detect_default_function_pub_fn() {
1638        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1639        let _ = fs::remove_dir_all(&temp_dir);
1640        fs::create_dir_all(temp_dir.join("src")).unwrap();
1641
1642        // Create a lib.rs with a public benchmark function
1643        let lib_content = r#"
1644#[benchmark]
1645pub fn public_bench() {
1646    // benchmark code
1647}
1648"#;
1649        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1650
1651        let result = detect_default_function(&temp_dir, "test-crate");
1652        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1653
1654        // Cleanup
1655        fs::remove_dir_all(&temp_dir).ok();
1656    }
1657
1658    #[test]
1659    fn test_resolve_default_function_fallback() {
1660        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1661        let _ = fs::remove_dir_all(&temp_dir);
1662        fs::create_dir_all(&temp_dir).unwrap();
1663
1664        // No lib.rs exists, should fall back to default
1665        let result = resolve_default_function(&temp_dir, "my-crate", None);
1666        assert_eq!(result, "my_crate::example_benchmark");
1667
1668        // Cleanup
1669        fs::remove_dir_all(&temp_dir).ok();
1670    }
1671
1672    #[test]
1673    fn test_sanitize_bundle_id_component() {
1674        // Hyphens should be removed
1675        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1676        // Underscores should be removed
1677        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1678        // Mixed separators should all be removed
1679        assert_eq!(
1680            sanitize_bundle_id_component("my-project_name"),
1681            "myprojectname"
1682        );
1683        // Already valid should remain unchanged (but lowercase)
1684        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1685        // Numbers should be preserved
1686        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1687        // Uppercase should be lowercased
1688        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1689        // Complex case
1690        assert_eq!(
1691            sanitize_bundle_id_component("My-Complex_Project-123"),
1692            "mycomplexproject123"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_generate_ios_project_bundle_id_not_duplicated() {
1698        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1699        // Clean up any previous test run
1700        let _ = fs::remove_dir_all(&temp_dir);
1701        fs::create_dir_all(&temp_dir).unwrap();
1702
1703        // Use a crate name that would previously cause duplication
1704        let crate_name = "bench-mobile";
1705        let bundle_prefix = "dev.world.benchmobile";
1706        let project_pascal = "BenchRunner";
1707
1708        let result = generate_ios_project(
1709            &temp_dir,
1710            crate_name,
1711            project_pascal,
1712            bundle_prefix,
1713            "bench_mobile::test_func",
1714        );
1715        assert!(
1716            result.is_ok(),
1717            "generate_ios_project failed: {:?}",
1718            result.err()
1719        );
1720
1721        // Verify project.yml was created
1722        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
1723        assert!(project_yml_path.exists(), "project.yml should exist");
1724
1725        // Read and verify the bundle ID is correct (not duplicated)
1726        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
1727
1728        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
1729        // NOT "dev.world.benchmobile.benchmobile"
1730        assert!(
1731            project_yml.contains("dev.world.benchmobile.BenchRunner"),
1732            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
1733            project_yml
1734        );
1735        assert!(
1736            !project_yml.contains("dev.world.benchmobile.benchmobile"),
1737            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
1738            project_yml
1739        );
1740        assert!(
1741            project_yml.contains("embed: false"),
1742            "Static xcframework dependency should be link-only, got:\n{}",
1743            project_yml
1744        );
1745
1746        // Cleanup
1747        fs::remove_dir_all(&temp_dir).ok();
1748    }
1749
1750    #[test]
1751    fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
1752        let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
1753        let _ = fs::remove_dir_all(&temp_dir);
1754        fs::create_dir_all(&temp_dir).unwrap();
1755
1756        generate_ios_project(
1757            &temp_dir,
1758            "bench_mobile",
1759            "BenchRunner",
1760            "dev.world.benchmobile",
1761            "bench_mobile::bench_prepare",
1762        )
1763        .unwrap();
1764
1765        let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
1766        fs::create_dir_all(resources_dir.join("nested")).unwrap();
1767        fs::write(
1768            resources_dir.join("bench_spec.json"),
1769            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
1770        )
1771        .unwrap();
1772        fs::write(
1773            resources_dir.join("bench_meta.json"),
1774            r#"{"build_id":"build-123"}"#,
1775        )
1776        .unwrap();
1777        fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
1778
1779        generate_ios_project(
1780            &temp_dir,
1781            "bench_mobile",
1782            "BenchRunner",
1783            "dev.world.benchmobile",
1784            "bench_mobile::bench_prepare",
1785        )
1786        .unwrap();
1787
1788        assert_eq!(
1789            fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
1790            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
1791        );
1792        assert_eq!(
1793            fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
1794            r#"{"build_id":"build-123"}"#
1795        );
1796        assert_eq!(
1797            fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
1798            "keep me"
1799        );
1800
1801        fs::remove_dir_all(&temp_dir).ok();
1802    }
1803
1804    #[test]
1805    fn test_ensure_ios_project_refreshes_existing_content_view_template() {
1806        let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
1807        let _ = fs::remove_dir_all(&temp_dir);
1808        fs::create_dir_all(&temp_dir).unwrap();
1809
1810        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1811            .expect("initial iOS project generation should succeed");
1812
1813        let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
1814        assert!(content_view_path.exists(), "ContentView.swift should exist");
1815
1816        fs::write(&content_view_path, "stale generated content").unwrap();
1817
1818        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1819            .expect("refreshing existing iOS project should succeed");
1820
1821        let refreshed = fs::read_to_string(&content_view_path).unwrap();
1822        assert!(
1823            refreshed.contains("ProfileLaunchOptions"),
1824            "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
1825            refreshed
1826        );
1827        assert!(
1828            refreshed.contains("repeatUntilMs"),
1829            "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
1830            refreshed
1831        );
1832        assert!(
1833            refreshed.contains("Task.detached(priority: .userInitiated)"),
1834            "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
1835            refreshed
1836        );
1837        assert!(
1838            refreshed.contains("await MainActor.run"),
1839            "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
1840            refreshed
1841        );
1842
1843        fs::remove_dir_all(&temp_dir).ok();
1844    }
1845
1846    #[test]
1847    fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
1848        let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
1849        let _ = fs::remove_dir_all(&temp_dir);
1850        fs::create_dir_all(&temp_dir).unwrap();
1851
1852        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1853            .expect("initial iOS project generation should succeed");
1854
1855        let ui_test_path =
1856            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1857        assert!(
1858            ui_test_path.exists(),
1859            "BenchRunnerUITests.swift should exist"
1860        );
1861
1862        fs::write(&ui_test_path, "stale generated content").unwrap();
1863
1864        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
1865            .expect("refreshing existing iOS project should succeed");
1866
1867        let refreshed = fs::read_to_string(&ui_test_path).unwrap();
1868        assert!(
1869            refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
1870            "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
1871            refreshed
1872        );
1873        assert!(
1874            refreshed.contains(
1875                "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
1876            ),
1877            "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
1878            refreshed
1879        );
1880
1881        fs::remove_dir_all(&temp_dir).ok();
1882    }
1883
1884    #[test]
1885    fn test_generate_ios_project_uses_configured_benchmark_timeout() {
1886        let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
1887        let _ = fs::remove_dir_all(&temp_dir);
1888        fs::create_dir_all(&temp_dir).unwrap();
1889
1890        let result = generate_ios_project_with_timeout(
1891            &temp_dir,
1892            "sample_fns",
1893            "BenchRunner",
1894            "dev.world.samplefns",
1895            "sample_fns::example_benchmark",
1896            1200,
1897        );
1898
1899        assert!(result.is_ok(), "generate_ios_project should succeed");
1900
1901        let ui_test_path =
1902            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1903        let contents = fs::read_to_string(&ui_test_path).unwrap();
1904        assert!(
1905            contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
1906            "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
1907            contents
1908        );
1909
1910        fs::remove_dir_all(&temp_dir).ok();
1911    }
1912
1913    #[test]
1914    fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
1915        assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
1916        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
1917        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
1918        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
1919    }
1920
1921    #[test]
1922    fn test_cross_platform_naming_consistency() {
1923        // Test that Android and iOS use the same naming convention for package/bundle IDs
1924        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
1925        let _ = fs::remove_dir_all(&temp_dir);
1926        fs::create_dir_all(&temp_dir).unwrap();
1927
1928        let project_name = "bench-mobile";
1929
1930        // Generate Android project
1931        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
1932        assert!(
1933            result.is_ok(),
1934            "generate_android_project failed: {:?}",
1935            result.err()
1936        );
1937
1938        // Generate iOS project (mimicking how ensure_ios_project does it)
1939        let bundle_id_component = sanitize_bundle_id_component(project_name);
1940        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1941        let result = generate_ios_project(
1942            &temp_dir,
1943            &project_name.replace('-', "_"),
1944            "BenchRunner",
1945            &bundle_prefix,
1946            "bench_mobile::test_func",
1947        );
1948        assert!(
1949            result.is_ok(),
1950            "generate_ios_project failed: {:?}",
1951            result.err()
1952        );
1953
1954        // Read Android build.gradle to extract package name
1955        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
1956            .expect("Failed to read Android build.gradle");
1957
1958        // Read iOS project.yml to extract bundle ID prefix
1959        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
1960            .expect("Failed to read iOS project.yml");
1961
1962        // Both should use "benchmobile" (without hyphens or underscores)
1963        // Android: namespace = "dev.world.benchmobile"
1964        // iOS: bundleIdPrefix: dev.world.benchmobile
1965        assert!(
1966            android_build_gradle.contains("dev.world.benchmobile"),
1967            "Android package should be 'dev.world.benchmobile', got:\n{}",
1968            android_build_gradle
1969        );
1970        assert!(
1971            ios_project_yml.contains("dev.world.benchmobile"),
1972            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
1973            ios_project_yml
1974        );
1975
1976        // Ensure Android doesn't use hyphens or underscores in the package ID component
1977        assert!(
1978            !android_build_gradle.contains("dev.world.bench-mobile"),
1979            "Android package should NOT contain hyphens"
1980        );
1981        assert!(
1982            !android_build_gradle.contains("dev.world.bench_mobile"),
1983            "Android package should NOT contain underscores"
1984        );
1985
1986        // Cleanup
1987        fs::remove_dir_all(&temp_dir).ok();
1988    }
1989
1990    #[test]
1991    fn test_cross_platform_version_consistency() {
1992        // Test that Android and iOS use the same version strings
1993        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
1994        let _ = fs::remove_dir_all(&temp_dir);
1995        fs::create_dir_all(&temp_dir).unwrap();
1996
1997        let project_name = "test-project";
1998
1999        // Generate Android project
2000        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
2001        assert!(
2002            result.is_ok(),
2003            "generate_android_project failed: {:?}",
2004            result.err()
2005        );
2006
2007        // Generate iOS project
2008        let bundle_id_component = sanitize_bundle_id_component(project_name);
2009        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2010        let result = generate_ios_project(
2011            &temp_dir,
2012            &project_name.replace('-', "_"),
2013            "BenchRunner",
2014            &bundle_prefix,
2015            "test_project::test_func",
2016        );
2017        assert!(
2018            result.is_ok(),
2019            "generate_ios_project failed: {:?}",
2020            result.err()
2021        );
2022
2023        // Read Android build.gradle
2024        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2025            .expect("Failed to read Android build.gradle");
2026
2027        // Read iOS project.yml
2028        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2029            .expect("Failed to read iOS project.yml");
2030
2031        // Both should use version "1.0.0"
2032        assert!(
2033            android_build_gradle.contains("versionName \"1.0.0\""),
2034            "Android versionName should be '1.0.0', got:\n{}",
2035            android_build_gradle
2036        );
2037        assert!(
2038            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2039            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2040            ios_project_yml
2041        );
2042
2043        // Cleanup
2044        fs::remove_dir_all(&temp_dir).ok();
2045    }
2046
2047    #[test]
2048    fn test_bundle_id_prefix_consistency() {
2049        // Test that the bundle ID prefix format is consistent across platforms
2050        let test_cases = vec![
2051            ("my-project", "dev.world.myproject"),
2052            ("bench_mobile", "dev.world.benchmobile"),
2053            ("TestApp", "dev.world.testapp"),
2054            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2055            (
2056                "app_with_many_underscores",
2057                "dev.world.appwithmanyunderscores",
2058            ),
2059        ];
2060
2061        for (input, expected_prefix) in test_cases {
2062            let sanitized = sanitize_bundle_id_component(input);
2063            let full_prefix = format!("dev.world.{}", sanitized);
2064            assert_eq!(
2065                full_prefix, expected_prefix,
2066                "For input '{}', expected '{}' but got '{}'",
2067                input, expected_prefix, full_prefix
2068            );
2069        }
2070    }
2071}