Skip to main content

mobench_sdk/
codegen.rs

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