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