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(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?;
64        }
65        Target::Both => {
66            generate_android_project(output_dir, &project_slug, default_function)?;
67            generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?;
68        }
69    }
70
71    // Generate config file
72    generate_config_file(output_dir, config)?;
73
74    // Generate examples if requested
75    if config.generate_examples {
76        generate_example_benchmarks(output_dir)?;
77    }
78
79    Ok(output_dir.clone())
80}
81
82/// Generates the bench-mobile FFI wrapper crate
83fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
84    let crate_dir = output_dir.join("bench-mobile");
85    fs::create_dir_all(crate_dir.join("src"))?;
86
87    let crate_name = format!("{}-bench-mobile", project_name);
88
89    // Generate Cargo.toml
90    // Note: We configure rustls to use 'ring' instead of 'aws-lc-rs' (default in rustls 0.23+)
91    // because aws-lc-rs doesn't compile for Android NDK targets.
92    let cargo_toml = format!(
93        r#"[package]
94name = "{}"
95version = "0.1.0"
96edition = "2021"
97
98[lib]
99crate-type = ["cdylib", "staticlib", "rlib"]
100
101[dependencies]
102mobench-sdk = {{ path = ".." }}
103uniffi = "0.28"
104{} = {{ path = ".." }}
105
106[features]
107default = []
108
109[build-dependencies]
110uniffi = {{ version = "0.28", features = ["build"] }}
111
112# Binary for generating UniFFI bindings (used by mobench build)
113[[bin]]
114name = "uniffi-bindgen"
115path = "src/bin/uniffi-bindgen.rs"
116
117# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
118# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
119# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
120#
121# Add this to your root Cargo.toml:
122# [workspace.dependencies]
123# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
124#
125# Then in each crate that uses rustls:
126# [dependencies]
127# rustls = {{ workspace = true }}
128"#,
129        crate_name, project_name
130    );
131
132    fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
133
134    // Generate src/lib.rs
135    let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
136//!
137//! This crate provides the FFI boundary between Rust benchmarks and mobile
138//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
139
140use uniffi;
141
142// Ensure the user crate is linked so benchmark registrations are pulled in.
143extern crate {{USER_CRATE}} as _bench_user_crate;
144
145// Re-export mobench-sdk types with UniFFI annotations
146#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
147pub struct BenchSpec {
148    pub name: String,
149    pub iterations: u32,
150    pub warmup: u32,
151}
152
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
154pub struct BenchSample {
155    pub duration_ns: u64,
156}
157
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
159pub struct BenchReport {
160    pub spec: BenchSpec,
161    pub samples: Vec<BenchSample>,
162}
163
164#[derive(Debug, thiserror::Error, uniffi::Error)]
165#[uniffi(flat_error)]
166pub enum BenchError {
167    #[error("iterations must be greater than zero")]
168    InvalidIterations,
169
170    #[error("unknown benchmark function: {name}")]
171    UnknownFunction { name: String },
172
173    #[error("benchmark execution failed: {reason}")]
174    ExecutionFailed { reason: String },
175}
176
177// Convert from mobench-sdk types
178impl From<mobench_sdk::BenchSpec> for BenchSpec {
179    fn from(spec: mobench_sdk::BenchSpec) -> Self {
180        Self {
181            name: spec.name,
182            iterations: spec.iterations,
183            warmup: spec.warmup,
184        }
185    }
186}
187
188impl From<BenchSpec> for mobench_sdk::BenchSpec {
189    fn from(spec: BenchSpec) -> Self {
190        Self {
191            name: spec.name,
192            iterations: spec.iterations,
193            warmup: spec.warmup,
194        }
195    }
196}
197
198impl From<mobench_sdk::BenchSample> for BenchSample {
199    fn from(sample: mobench_sdk::BenchSample) -> Self {
200        Self {
201            duration_ns: sample.duration_ns,
202        }
203    }
204}
205
206impl From<mobench_sdk::RunnerReport> for BenchReport {
207    fn from(report: mobench_sdk::RunnerReport) -> Self {
208        Self {
209            spec: report.spec.into(),
210            samples: report.samples.into_iter().map(Into::into).collect(),
211        }
212    }
213}
214
215impl From<mobench_sdk::BenchError> for BenchError {
216    fn from(err: mobench_sdk::BenchError) -> Self {
217        match err {
218            mobench_sdk::BenchError::Runner(runner_err) => {
219                BenchError::ExecutionFailed {
220                    reason: runner_err.to_string(),
221                }
222            }
223            mobench_sdk::BenchError::UnknownFunction(name) => {
224                BenchError::UnknownFunction { name }
225            }
226            _ => BenchError::ExecutionFailed {
227                reason: err.to_string(),
228            },
229        }
230    }
231}
232
233/// Runs a benchmark by name with the given specification
234///
235/// This is the main FFI entry point called from mobile platforms.
236#[uniffi::export]
237pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
238    let sdk_spec: mobench_sdk::BenchSpec = spec.into();
239    let report = mobench_sdk::run_benchmark(sdk_spec)?;
240    Ok(report.into())
241}
242
243// Generate UniFFI scaffolding
244uniffi::setup_scaffolding!();
245"#;
246
247    let lib_rs = render_template(
248        lib_rs_template,
249        &[TemplateVar {
250            name: "USER_CRATE",
251            value: project_name.replace('-', "_"),
252        }],
253    );
254    fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
255
256    // Generate build.rs
257    let build_rs = r#"fn main() {
258    uniffi::generate_scaffolding("src/lib.rs").unwrap();
259}
260"#;
261
262    fs::write(crate_dir.join("build.rs"), build_rs)?;
263
264    // Generate uniffi-bindgen binary (used by mobench build)
265    let bin_dir = crate_dir.join("src/bin");
266    fs::create_dir_all(&bin_dir)?;
267    let uniffi_bindgen_rs = r#"fn main() {
268    uniffi::uniffi_bindgen_main()
269}
270"#;
271    fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
272
273    Ok(())
274}
275
276/// Generates Android project structure from templates
277///
278/// This function can be called standalone to generate just the Android
279/// project scaffolding, useful for auto-generation during build.
280///
281/// # Arguments
282///
283/// * `output_dir` - Directory to write the `android/` project into
284/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
285/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
286pub fn generate_android_project(
287    output_dir: &Path,
288    project_slug: &str,
289    default_function: &str,
290) -> Result<(), BenchError> {
291    let target_dir = output_dir.join("android");
292    let library_name = project_slug.replace('-', "_");
293    let project_pascal = to_pascal_case(project_slug);
294    let vars = vec![
295        TemplateVar {
296            name: "PROJECT_NAME",
297            value: project_slug.to_string(),
298        },
299        TemplateVar {
300            name: "PROJECT_NAME_PASCAL",
301            value: project_pascal.clone(),
302        },
303        TemplateVar {
304            name: "APP_NAME",
305            value: format!("{} Benchmark", project_pascal),
306        },
307        TemplateVar {
308            name: "PACKAGE_NAME",
309            value: format!("dev.world.{}", project_slug),
310        },
311        TemplateVar {
312            name: "UNIFFI_NAMESPACE",
313            value: library_name.clone(),
314        },
315        TemplateVar {
316            name: "LIBRARY_NAME",
317            value: library_name,
318        },
319        TemplateVar {
320            name: "DEFAULT_FUNCTION",
321            value: default_function.to_string(),
322        },
323    ];
324    render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
325    Ok(())
326}
327
328/// Generates iOS project structure from templates
329///
330/// This function can be called standalone to generate just the iOS
331/// project scaffolding, useful for auto-generation during build.
332///
333/// # Arguments
334///
335/// * `output_dir` - Directory to write the `ios/` project into
336/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
337/// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile")
338/// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench")
339/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
340pub fn generate_ios_project(
341    output_dir: &Path,
342    project_slug: &str,
343    project_pascal: &str,
344    bundle_prefix: &str,
345    default_function: &str,
346) -> Result<(), BenchError> {
347    let target_dir = output_dir.join("ios");
348    // Sanitize bundle ID components to ensure they only contain alphanumeric characters
349    // iOS bundle identifiers should not contain hyphens or underscores
350    let sanitized_bundle_prefix = {
351        let parts: Vec<&str> = bundle_prefix.split('.').collect();
352        parts.iter()
353            .map(|part| sanitize_bundle_id_component(part))
354            .collect::<Vec<_>>()
355            .join(".")
356    };
357    let sanitized_project_slug = sanitize_bundle_id_component(project_slug);
358    let vars = vec![
359        TemplateVar {
360            name: "DEFAULT_FUNCTION",
361            value: default_function.to_string(),
362        },
363        TemplateVar {
364            name: "PROJECT_NAME_PASCAL",
365            value: project_pascal.to_string(),
366        },
367        TemplateVar {
368            name: "BUNDLE_ID_PREFIX",
369            value: sanitized_bundle_prefix.clone(),
370        },
371        TemplateVar {
372            name: "BUNDLE_ID",
373            value: format!("{}.{}", sanitized_bundle_prefix, sanitized_project_slug),
374        },
375        TemplateVar {
376            name: "LIBRARY_NAME",
377            value: project_slug.replace('-', "_"),
378        },
379    ];
380    render_dir(&IOS_TEMPLATES, &target_dir, &vars)?;
381    Ok(())
382}
383
384/// Generates bench-config.toml configuration file
385fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
386    let config_target = match config.target {
387        Target::Ios => "ios",
388        Target::Android | Target::Both => "android",
389    };
390    let config_content = format!(
391        r#"# mobench configuration
392# This file controls how benchmarks are executed on devices.
393
394target = "{}"
395function = "example_fibonacci"
396iterations = 100
397warmup = 10
398device_matrix = "device-matrix.yaml"
399device_tags = ["default"]
400
401[browserstack]
402app_automate_username = "${{BROWSERSTACK_USERNAME}}"
403app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
404project = "{}-benchmarks"
405
406[ios_xcuitest]
407app = "target/ios/BenchRunner.ipa"
408test_suite = "target/ios/BenchRunnerUITests.zip"
409"#,
410        config_target, config.project_name
411    );
412
413    fs::write(output_dir.join("bench-config.toml"), config_content)?;
414
415    Ok(())
416}
417
418/// Generates example benchmark functions
419fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
420    let examples_dir = output_dir.join("benches");
421    fs::create_dir_all(&examples_dir)?;
422
423    let example_content = r#"//! Example benchmarks
424//!
425//! This file demonstrates how to write benchmarks with mobench-sdk.
426
427use mobench_sdk::benchmark;
428
429/// Simple benchmark example
430#[benchmark]
431fn example_fibonacci() {
432    let result = fibonacci(30);
433    std::hint::black_box(result);
434}
435
436/// Another example with a loop
437#[benchmark]
438fn example_sum() {
439    let mut sum = 0u64;
440    for i in 0..10000 {
441        sum = sum.wrapping_add(i);
442    }
443    std::hint::black_box(sum);
444}
445
446// Helper function (not benchmarked)
447fn fibonacci(n: u32) -> u64 {
448    match n {
449        0 => 0,
450        1 => 1,
451        _ => {
452            let mut a = 0u64;
453            let mut b = 1u64;
454            for _ in 2..=n {
455                let next = a.wrapping_add(b);
456                a = b;
457                b = next;
458            }
459            b
460        }
461    }
462}
463"#;
464
465    fs::write(examples_dir.join("example.rs"), example_content)?;
466
467    Ok(())
468}
469
470/// File extensions that should be processed for template variable substitution
471const TEMPLATE_EXTENSIONS: &[&str] = &[
472    "gradle", "xml", "kt", "java", "swift", "yml", "yaml", "json", "toml", "md", "txt", "h", "m",
473    "plist", "pbxproj", "xcscheme", "xcworkspacedata", "entitlements", "modulemap",
474];
475
476fn render_dir(
477    dir: &Dir,
478    out_root: &Path,
479    vars: &[TemplateVar],
480) -> Result<(), BenchError> {
481    for entry in dir.entries() {
482        match entry {
483            DirEntry::Dir(sub) => {
484                // Skip cache directories
485                if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
486                    continue;
487                }
488                render_dir(sub, out_root, vars)?;
489            }
490            DirEntry::File(file) => {
491                if file.path().components().any(|c| c.as_os_str() == ".gradle") {
492                    continue;
493                }
494                // file.path() returns the full relative path from the embedded dir root
495                let mut relative = file.path().to_path_buf();
496                let mut contents = file.contents().to_vec();
497
498                // Check if file has .template extension (explicit template)
499                let is_explicit_template = relative
500                    .extension()
501                    .map(|ext| ext == "template")
502                    .unwrap_or(false);
503
504                // Check if file is a text file that should be processed for templates
505                let should_render = is_explicit_template || is_template_file(&relative);
506
507                if is_explicit_template {
508                    // Remove .template extension from output filename
509                    relative.set_extension("");
510                }
511
512                if should_render {
513                    if let Ok(text) = std::str::from_utf8(&contents) {
514                        let rendered = render_template(text, vars);
515                        // Validate that all template variables were replaced
516                        validate_no_unreplaced_placeholders(&rendered, &relative)?;
517                        contents = rendered.into_bytes();
518                    }
519                }
520
521                let out_path = out_root.join(relative);
522                if let Some(parent) = out_path.parent() {
523                    fs::create_dir_all(parent)?;
524                }
525                fs::write(&out_path, contents)?;
526            }
527        }
528    }
529    Ok(())
530}
531
532/// Checks if a file should be processed for template variable substitution
533/// based on its extension
534fn is_template_file(path: &Path) -> bool {
535    // Check for .template extension on any file
536    if let Some(ext) = path.extension() {
537        if ext == "template" {
538            return true;
539        }
540        // Check if the base extension is in our list
541        if let Some(ext_str) = ext.to_str() {
542            return TEMPLATE_EXTENSIONS.contains(&ext_str);
543        }
544    }
545    // Also check the filename without the .template extension
546    if let Some(stem) = path.file_stem() {
547        let stem_path = Path::new(stem);
548        if let Some(ext) = stem_path.extension() {
549            if let Some(ext_str) = ext.to_str() {
550                return TEMPLATE_EXTENSIONS.contains(&ext_str);
551            }
552        }
553    }
554    false
555}
556
557/// Validates that no unreplaced template placeholders remain in the rendered content
558fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
559    // Find all {{...}} patterns
560    let mut pos = 0;
561    let mut unreplaced = Vec::new();
562
563    while let Some(start) = content[pos..].find("{{") {
564        let abs_start = pos + start;
565        if let Some(end) = content[abs_start..].find("}}") {
566            let placeholder = &content[abs_start..abs_start + end + 2];
567            // Extract just the variable name
568            let var_name = &content[abs_start + 2..abs_start + end];
569            // Skip placeholders that look like Gradle variable syntax (e.g., ${...})
570            // or other non-template patterns
571            if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
572                unreplaced.push(placeholder.to_string());
573            }
574            pos = abs_start + end + 2;
575        } else {
576            break;
577        }
578    }
579
580    if !unreplaced.is_empty() {
581        return Err(BenchError::Build(format!(
582            "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
583             This is a bug in mobench-sdk. Please report it at:\n\
584             https://github.com/worldcoin/mobile-bench-rs/issues",
585            file_path, unreplaced
586        )));
587    }
588
589    Ok(())
590}
591
592fn render_template(input: &str, vars: &[TemplateVar]) -> String {
593    let mut output = input.to_string();
594    for var in vars {
595        output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
596    }
597    output
598}
599
600/// Sanitizes a string to be a valid iOS bundle identifier component
601///
602/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9),
603/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency,
604/// this function converts all non-alphanumeric characters to lowercase letters only.
605///
606/// Examples:
607/// - "bench-mobile" -> "benchmobile"
608/// - "bench_mobile" -> "benchmobile"
609/// - "my-project_name" -> "myprojectname"
610pub fn sanitize_bundle_id_component(name: &str) -> String {
611    name.chars()
612        .filter(|c| c.is_ascii_alphanumeric())
613        .collect::<String>()
614        .to_lowercase()
615}
616
617fn sanitize_package_name(name: &str) -> String {
618    name.chars()
619        .map(|c| {
620            if c.is_ascii_alphanumeric() {
621                c.to_ascii_lowercase()
622            } else {
623                '-'
624            }
625        })
626        .collect::<String>()
627        .trim_matches('-')
628        .replace("--", "-")
629}
630
631/// Converts a string to PascalCase
632pub fn to_pascal_case(input: &str) -> String {
633    input
634        .split(|c: char| !c.is_ascii_alphanumeric())
635        .filter(|s| !s.is_empty())
636        .map(|s| {
637            let mut chars = s.chars();
638            let first = chars.next().unwrap().to_ascii_uppercase();
639            let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
640            format!("{}{}", first, rest)
641        })
642        .collect::<String>()
643}
644
645/// Checks if the Android project scaffolding exists at the given output directory
646///
647/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists.
648pub fn android_project_exists(output_dir: &Path) -> bool {
649    let android_dir = output_dir.join("android");
650    android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
651}
652
653/// Checks if the iOS project scaffolding exists at the given output directory
654///
655/// Returns true if the `ios/BenchRunner/project.yml` file exists.
656pub fn ios_project_exists(output_dir: &Path) -> bool {
657    output_dir.join("ios/BenchRunner/project.yml").exists()
658}
659
660/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
661///
662/// This function looks for functions marked with the `#[benchmark]` attribute and returns
663/// the first one found in the format `{crate_name}::{function_name}`.
664///
665/// # Arguments
666///
667/// * `crate_dir` - Path to the crate directory containing Cargo.toml
668/// * `crate_name` - Name of the crate (used as prefix for the function name)
669///
670/// # Returns
671///
672/// * `Some(String)` - The detected function name in format `crate_name::function_name`
673/// * `None` - If no benchmark functions are found or if the file cannot be read
674pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
675    let lib_rs = crate_dir.join("src/lib.rs");
676    if !lib_rs.exists() {
677        return None;
678    }
679
680    let file = fs::File::open(&lib_rs).ok()?;
681    let reader = BufReader::new(file);
682
683    let mut found_benchmark_attr = false;
684    let crate_name_normalized = crate_name.replace('-', "_");
685
686    for line in reader.lines().map_while(Result::ok) {
687        let trimmed = line.trim();
688
689        // Check for #[benchmark] attribute
690        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
691            found_benchmark_attr = true;
692            continue;
693        }
694
695        // If we found a benchmark attribute, look for the function definition
696        if found_benchmark_attr {
697            // Look for "fn function_name" or "pub fn function_name"
698            if let Some(fn_pos) = trimmed.find("fn ") {
699                let after_fn = &trimmed[fn_pos + 3..];
700                // Extract function name (until '(' or whitespace)
701                let fn_name: String = after_fn
702                    .chars()
703                    .take_while(|c| c.is_alphanumeric() || *c == '_')
704                    .collect();
705
706                if !fn_name.is_empty() {
707                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
708                }
709            }
710            // Reset if we hit a line that's not a function definition
711            // (could be another attribute or comment)
712            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
713                found_benchmark_attr = false;
714            }
715        }
716    }
717
718    None
719}
720
721/// Resolves the default benchmark function for a project
722///
723/// This function attempts to auto-detect benchmark functions from the crate's source.
724/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
725///
726/// # Arguments
727///
728/// * `project_root` - Root directory of the project
729/// * `crate_name` - Name of the benchmark crate
730/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
731///
732/// # Returns
733///
734/// The default function name in format `crate_name::function_name`
735pub fn resolve_default_function(
736    project_root: &Path,
737    crate_name: &str,
738    crate_dir: Option<&Path>,
739) -> String {
740    let crate_name_normalized = crate_name.replace('-', "_");
741
742    // Try to find the crate directory
743    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
744        vec![dir.to_path_buf()]
745    } else {
746        vec![
747            project_root.join("bench-mobile"),
748            project_root.join("crates").join(crate_name),
749            project_root.to_path_buf(),
750        ]
751    };
752
753    // Try to detect benchmarks from each potential location
754    for dir in &search_dirs {
755        if dir.join("Cargo.toml").exists() {
756            if let Some(detected) = detect_default_function(dir, &crate_name_normalized) {
757                return detected;
758            }
759        }
760    }
761
762    // Fallback: use a sensible default based on crate name
763    format!("{}::example_benchmark", crate_name_normalized)
764}
765
766/// Auto-generates Android project scaffolding from a crate name
767///
768/// This is a convenience function that derives template variables from the
769/// crate name and generates the Android project structure. It auto-detects
770/// the default benchmark function from the crate's source code.
771///
772/// # Arguments
773///
774/// * `output_dir` - Directory to write the `android/` project into
775/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
776pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
777    ensure_android_project_with_options(output_dir, crate_name, None, None)
778}
779
780/// Auto-generates Android project scaffolding with additional options
781///
782/// This is a more flexible version of `ensure_android_project` that allows
783/// specifying a custom default function and/or crate directory.
784///
785/// # Arguments
786///
787/// * `output_dir` - Directory to write the `android/` project into
788/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
789/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
790/// * `crate_dir` - Optional explicit crate directory for benchmark detection
791pub fn ensure_android_project_with_options(
792    output_dir: &Path,
793    crate_name: &str,
794    project_root: Option<&Path>,
795    crate_dir: Option<&Path>,
796) -> Result<(), BenchError> {
797    if android_project_exists(output_dir) {
798        return Ok(());
799    }
800
801    println!("Android project not found, generating scaffolding...");
802    let project_slug = crate_name.replace('-', "_");
803
804    // Resolve the default function by auto-detecting from source
805    let effective_root = project_root.unwrap_or_else(|| {
806        output_dir.parent().unwrap_or(output_dir)
807    });
808    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
809
810    generate_android_project(output_dir, &project_slug, &default_function)?;
811    println!("  Generated Android project at {:?}", output_dir.join("android"));
812    println!("  Default benchmark function: {}", default_function);
813    Ok(())
814}
815
816/// Auto-generates iOS project scaffolding from a crate name
817///
818/// This is a convenience function that derives template variables from the
819/// crate name and generates the iOS project structure. It auto-detects
820/// the default benchmark function from the crate's source code.
821///
822/// # Arguments
823///
824/// * `output_dir` - Directory to write the `ios/` project into
825/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
826pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
827    ensure_ios_project_with_options(output_dir, crate_name, None, None)
828}
829
830/// Auto-generates iOS project scaffolding with additional options
831///
832/// This is a more flexible version of `ensure_ios_project` that allows
833/// specifying a custom default function and/or crate directory.
834///
835/// # Arguments
836///
837/// * `output_dir` - Directory to write the `ios/` project into
838/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
839/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
840/// * `crate_dir` - Optional explicit crate directory for benchmark detection
841pub fn ensure_ios_project_with_options(
842    output_dir: &Path,
843    crate_name: &str,
844    project_root: Option<&Path>,
845    crate_dir: Option<&Path>,
846) -> Result<(), BenchError> {
847    if ios_project_exists(output_dir) {
848        return Ok(());
849    }
850
851    println!("iOS project not found, generating scaffolding...");
852    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
853    let project_pascal = "BenchRunner";
854    // Derive library name and bundle prefix from crate name
855    let library_name = crate_name.replace('-', "_");
856    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
857    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
858    let bundle_id_component = sanitize_bundle_id_component(crate_name);
859    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
860
861    // Resolve the default function by auto-detecting from source
862    let effective_root = project_root.unwrap_or_else(|| {
863        output_dir.parent().unwrap_or(output_dir)
864    });
865    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
866
867    generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix, &default_function)?;
868    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
869    println!("  Default benchmark function: {}", default_function);
870    Ok(())
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use std::env;
877
878    #[test]
879    fn test_generate_bench_mobile_crate() {
880        let temp_dir = env::temp_dir().join("mobench-sdk-test");
881        fs::create_dir_all(&temp_dir).unwrap();
882
883        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
884        assert!(result.is_ok());
885
886        // Verify files were created
887        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
888        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
889        assert!(temp_dir.join("bench-mobile/build.rs").exists());
890
891        // Cleanup
892        fs::remove_dir_all(&temp_dir).ok();
893    }
894
895    #[test]
896    fn test_generate_android_project_no_unreplaced_placeholders() {
897        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
898        // Clean up any previous test run
899        let _ = fs::remove_dir_all(&temp_dir);
900        fs::create_dir_all(&temp_dir).unwrap();
901
902        let result = generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
903        assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err());
904
905        // Verify key files exist
906        let android_dir = temp_dir.join("android");
907        assert!(android_dir.join("settings.gradle").exists());
908        assert!(android_dir.join("app/build.gradle").exists());
909        assert!(android_dir.join("app/src/main/AndroidManifest.xml").exists());
910        assert!(android_dir.join("app/src/main/res/values/strings.xml").exists());
911        assert!(android_dir.join("app/src/main/res/values/themes.xml").exists());
912
913        // Verify no unreplaced placeholders remain in generated files
914        let files_to_check = [
915            "settings.gradle",
916            "app/build.gradle",
917            "app/src/main/AndroidManifest.xml",
918            "app/src/main/res/values/strings.xml",
919            "app/src/main/res/values/themes.xml",
920        ];
921
922        for file in files_to_check {
923            let path = android_dir.join(file);
924            let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file));
925
926            // Check for unreplaced placeholders
927            let has_placeholder = contents.contains("{{") && contents.contains("}}");
928            assert!(
929                !has_placeholder,
930                "File {} contains unreplaced template placeholders: {}",
931                file,
932                contents
933            );
934        }
935
936        // Verify specific substitutions were made
937        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
938        assert!(
939            settings.contains("my-bench-project-android") || settings.contains("my_bench_project-android"),
940            "settings.gradle should contain project name"
941        );
942
943        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
944        assert!(
945            build_gradle.contains("dev.world.my-bench-project") || build_gradle.contains("dev.world.my_bench_project"),
946            "build.gradle should contain package name"
947        );
948
949        let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
950        assert!(
951            manifest.contains("Theme.MyBenchProject"),
952            "AndroidManifest.xml should contain PascalCase theme name"
953        );
954
955        let strings = fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
956        assert!(
957            strings.contains("Benchmark"),
958            "strings.xml should contain app name with Benchmark"
959        );
960
961        // Cleanup
962        fs::remove_dir_all(&temp_dir).ok();
963    }
964
965    #[test]
966    fn test_is_template_file() {
967        assert!(is_template_file(Path::new("settings.gradle")));
968        assert!(is_template_file(Path::new("app/build.gradle")));
969        assert!(is_template_file(Path::new("AndroidManifest.xml")));
970        assert!(is_template_file(Path::new("strings.xml")));
971        assert!(is_template_file(Path::new("MainActivity.kt.template")));
972        assert!(is_template_file(Path::new("project.yml")));
973        assert!(is_template_file(Path::new("Info.plist")));
974        assert!(!is_template_file(Path::new("libfoo.so")));
975        assert!(!is_template_file(Path::new("image.png")));
976    }
977
978    #[test]
979    fn test_validate_no_unreplaced_placeholders() {
980        // Should pass with no placeholders
981        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
982
983        // Should pass with Gradle variables (not our placeholders)
984        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
985
986        // Should fail with unreplaced template placeholders
987        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
988        assert!(result.is_err());
989        let err = result.unwrap_err().to_string();
990        assert!(err.contains("{{NAME}}"));
991    }
992
993    #[test]
994    fn test_to_pascal_case() {
995        assert_eq!(to_pascal_case("my-project"), "MyProject");
996        assert_eq!(to_pascal_case("my_project"), "MyProject");
997        assert_eq!(to_pascal_case("myproject"), "Myproject");
998        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
999    }
1000
1001    #[test]
1002    fn test_detect_default_function_finds_benchmark() {
1003        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1004        let _ = fs::remove_dir_all(&temp_dir);
1005        fs::create_dir_all(temp_dir.join("src")).unwrap();
1006
1007        // Create a lib.rs with a benchmark function
1008        let lib_content = r#"
1009use mobench_sdk::benchmark;
1010
1011/// Some docs
1012#[benchmark]
1013fn my_benchmark_func() {
1014    // benchmark code
1015}
1016
1017fn helper_func() {}
1018"#;
1019        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1020        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1021
1022        let result = detect_default_function(&temp_dir, "my_crate");
1023        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1024
1025        // Cleanup
1026        fs::remove_dir_all(&temp_dir).ok();
1027    }
1028
1029    #[test]
1030    fn test_detect_default_function_no_benchmark() {
1031        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1032        let _ = fs::remove_dir_all(&temp_dir);
1033        fs::create_dir_all(temp_dir.join("src")).unwrap();
1034
1035        // Create a lib.rs without benchmark functions
1036        let lib_content = r#"
1037fn regular_function() {
1038    // no benchmark here
1039}
1040"#;
1041        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1042
1043        let result = detect_default_function(&temp_dir, "my_crate");
1044        assert!(result.is_none());
1045
1046        // Cleanup
1047        fs::remove_dir_all(&temp_dir).ok();
1048    }
1049
1050    #[test]
1051    fn test_detect_default_function_pub_fn() {
1052        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1053        let _ = fs::remove_dir_all(&temp_dir);
1054        fs::create_dir_all(temp_dir.join("src")).unwrap();
1055
1056        // Create a lib.rs with a public benchmark function
1057        let lib_content = r#"
1058#[benchmark]
1059pub fn public_bench() {
1060    // benchmark code
1061}
1062"#;
1063        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1064
1065        let result = detect_default_function(&temp_dir, "test-crate");
1066        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1067
1068        // Cleanup
1069        fs::remove_dir_all(&temp_dir).ok();
1070    }
1071
1072    #[test]
1073    fn test_resolve_default_function_fallback() {
1074        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1075        let _ = fs::remove_dir_all(&temp_dir);
1076        fs::create_dir_all(&temp_dir).unwrap();
1077
1078        // No lib.rs exists, should fall back to default
1079        let result = resolve_default_function(&temp_dir, "my-crate", None);
1080        assert_eq!(result, "my_crate::example_benchmark");
1081
1082        // Cleanup
1083        fs::remove_dir_all(&temp_dir).ok();
1084    }
1085
1086    #[test]
1087    fn test_sanitize_bundle_id_component() {
1088        // Hyphens should be removed
1089        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1090        // Underscores should be removed
1091        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1092        // Mixed separators should all be removed
1093        assert_eq!(sanitize_bundle_id_component("my-project_name"), "myprojectname");
1094        // Already valid should remain unchanged (but lowercase)
1095        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1096        // Numbers should be preserved
1097        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1098        // Uppercase should be lowercased
1099        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1100        // Complex case
1101        assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123");
1102    }
1103}