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