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