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