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");
15pub const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300;
16pub const DEFAULT_IOS_DEPLOYMENT_TARGET: &str = "15.0";
17pub const SWIFTUI_RUNNER_MIN_IOS: &str = "15.0";
18
19/// Supported generated iOS application runner templates.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum IosRunner {
22    /// Current SwiftUI runner. This is the default for iOS 15+.
23    Swiftui,
24    /// UIKit-based runner for legacy deployment targets.
25    UikitLegacy,
26}
27
28impl IosRunner {
29    pub fn parse(value: &str) -> Result<Self, BenchError> {
30        match value.trim().to_ascii_lowercase().as_str() {
31            "swiftui" => Ok(Self::Swiftui),
32            "uikit-legacy" | "uikit_legacy" => Ok(Self::UikitLegacy),
33            other => Err(BenchError::Build(format!(
34                "Unsupported iOS runner `{other}`. Supported values: swiftui, uikit-legacy"
35            ))),
36        }
37    }
38
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Swiftui => "swiftui",
42            Self::UikitLegacy => "uikit-legacy",
43        }
44    }
45}
46
47/// Parsed iOS deployment target used for explicit compatibility decisions.
48#[derive(Debug, Clone, Eq)]
49pub struct IosDeploymentTarget {
50    major: u16,
51    minor: u16,
52    patch: u16,
53    raw: String,
54}
55
56impl PartialEq for IosDeploymentTarget {
57    fn eq(&self, other: &Self) -> bool {
58        (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
59    }
60}
61
62impl Ord for IosDeploymentTarget {
63    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
64        (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
65    }
66}
67
68impl PartialOrd for IosDeploymentTarget {
69    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
70        Some(self.cmp(other))
71    }
72}
73
74impl IosDeploymentTarget {
75    pub fn parse(value: &str) -> Result<Self, BenchError> {
76        let raw = value.trim();
77        if raw.is_empty() {
78            return Err(BenchError::Build(
79                "iOS deployment target must not be empty".to_string(),
80            ));
81        }
82
83        let parts = raw.split('.').collect::<Vec<_>>();
84        if parts.len() > 3 {
85            return Err(BenchError::Build(format!(
86                "Invalid iOS deployment target `{raw}`. Expected VERSION like 15.0"
87            )));
88        }
89
90        let major = parse_ios_version_part(raw, parts[0], "major")?;
91        let minor = parts
92            .get(1)
93            .map(|part| parse_ios_version_part(raw, part, "minor"))
94            .transpose()?
95            .unwrap_or(0);
96        let patch = parts
97            .get(2)
98            .map(|part| parse_ios_version_part(raw, part, "patch"))
99            .transpose()?
100            .unwrap_or(0);
101
102        Ok(Self {
103            major,
104            minor,
105            patch,
106            raw: raw.to_string(),
107        })
108    }
109
110    pub fn default_target() -> Self {
111        Self::parse(DEFAULT_IOS_DEPLOYMENT_TARGET)
112            .expect("default iOS deployment target should be valid")
113    }
114}
115
116fn parse_ios_version_part(raw: &str, part: &str, label: &str) -> Result<u16, BenchError> {
117    if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
118        return Err(BenchError::Build(format!(
119            "Invalid iOS deployment target `{raw}`: {label} version component must be numeric"
120        )));
121    }
122    part.parse::<u16>().map_err(|err| {
123        BenchError::Build(format!(
124            "Invalid iOS deployment target `{raw}`: failed to parse {label} component: {err}"
125        ))
126    })
127}
128
129impl std::fmt::Display for IosDeploymentTarget {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.write_str(&self.raw)
132    }
133}
134
135/// Fully resolved iOS project generation options.
136#[derive(Debug, Clone)]
137pub struct IosProjectOptions {
138    pub deployment_target: IosDeploymentTarget,
139    pub runner: IosRunner,
140    pub ios_benchmark_timeout_secs: u64,
141}
142
143impl Default for IosProjectOptions {
144    fn default() -> Self {
145        Self {
146            deployment_target: IosDeploymentTarget::default_target(),
147            runner: IosRunner::Swiftui,
148            ios_benchmark_timeout_secs: DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS,
149        }
150    }
151}
152
153pub fn resolve_ios_runner(
154    deployment_target: &IosDeploymentTarget,
155    requested_runner: Option<IosRunner>,
156) -> Result<IosRunner, BenchError> {
157    let swiftui_floor = IosDeploymentTarget::parse(SWIFTUI_RUNNER_MIN_IOS)?;
158    match requested_runner {
159        Some(IosRunner::Swiftui) if deployment_target < &swiftui_floor => {
160            Err(BenchError::Build(format!(
161                "iOS runner `swiftui` requires deployment target {SWIFTUI_RUNNER_MIN_IOS}+; \
162                 requested deployment target is {deployment_target}. Use `uikit-legacy` or raise the deployment target."
163            )))
164        }
165        Some(runner) => Ok(runner),
166        None if deployment_target < &swiftui_floor => Ok(IosRunner::UikitLegacy),
167        None => Ok(IosRunner::Swiftui),
168    }
169}
170
171/// Template variable that can be replaced in template files
172#[derive(Debug, Clone)]
173pub struct TemplateVar {
174    pub name: &'static str,
175    pub value: String,
176}
177
178/// Generates a new mobile benchmark project from templates
179///
180/// Creates the necessary directory structure and files for benchmarking on
181/// mobile platforms. This includes:
182/// - A `bench-mobile/` crate for FFI bindings
183/// - Platform-specific app projects (Android and/or iOS)
184/// - Configuration files
185///
186/// # Arguments
187///
188/// * `config` - Configuration for project initialization
189///
190/// # Returns
191///
192/// * `Ok(PathBuf)` - Path to the generated project root
193/// * `Err(BenchError)` - If generation fails
194pub fn generate_project(config: &InitConfig) -> Result<PathBuf, BenchError> {
195    let output_dir = &config.output_dir;
196    let project_slug = sanitize_package_name(&config.project_name);
197    let project_pascal = to_pascal_case(&project_slug);
198    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
199    let bundle_id_component = sanitize_bundle_id_component(&project_slug);
200    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
201
202    // Create base directories
203    fs::create_dir_all(output_dir)?;
204
205    // Generate bench-mobile FFI wrapper crate
206    generate_bench_mobile_crate(output_dir, &project_slug)?;
207
208    // For full project generation (init), use "example_fibonacci" as the default
209    // since the generated example benchmarks include this function
210    let default_function = "example_fibonacci";
211
212    // Generate platform-specific projects
213    match config.target {
214        Target::Android => {
215            generate_android_project(output_dir, &project_slug, default_function)?;
216        }
217        Target::Ios => {
218            generate_ios_project(
219                output_dir,
220                &project_slug,
221                &project_pascal,
222                &bundle_prefix,
223                default_function,
224            )?;
225        }
226        Target::Both => {
227            generate_android_project(output_dir, &project_slug, default_function)?;
228            generate_ios_project(
229                output_dir,
230                &project_slug,
231                &project_pascal,
232                &bundle_prefix,
233                default_function,
234            )?;
235        }
236    }
237
238    // Generate config file
239    generate_config_file(output_dir, config)?;
240
241    // Generate examples if requested
242    if config.generate_examples {
243        generate_example_benchmarks(output_dir)?;
244    }
245
246    Ok(output_dir.clone())
247}
248
249/// Generates the bench-mobile FFI wrapper crate
250fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> {
251    let crate_dir = output_dir.join("bench-mobile");
252    fs::create_dir_all(crate_dir.join("src"))?;
253
254    let crate_name = format!("{}-bench-mobile", project_name);
255
256    // Generate Cargo.toml
257    // Note: We configure rustls to use 'ring' instead of 'aws-lc-rs' (default in rustls 0.23+)
258    // because aws-lc-rs doesn't compile for Android NDK targets.
259    let cargo_toml = format!(
260        r#"[package]
261name = "{}"
262version = "0.1.0"
263edition = "2021"
264
265[lib]
266crate-type = ["cdylib", "staticlib", "rlib"]
267
268[dependencies]
269mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }}
270uniffi = "0.28"
271{} = {{ path = ".." }}
272
273[features]
274default = []
275
276[build-dependencies]
277uniffi = {{ version = "0.28", features = ["build"] }}
278
279# Binary for generating UniFFI bindings (used by mobench build)
280[[bin]]
281name = "uniffi-bindgen"
282path = "src/bin/uniffi-bindgen.rs"
283
284# IMPORTANT: If your project uses rustls (directly or transitively), you must configure
285# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+).
286# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues.
287#
288# Add this to your root Cargo.toml:
289# [workspace.dependencies]
290# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }}
291#
292# Then in each crate that uses rustls:
293# [dependencies]
294# rustls = {{ workspace = true }}
295"#,
296        crate_name, project_name
297    );
298
299    fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
300
301    // Generate src/lib.rs
302    let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks
303//!
304//! This crate provides the FFI boundary between Rust benchmarks and mobile
305//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings.
306
307use uniffi;
308
309// Ensure the user crate is linked so benchmark registrations are pulled in.
310extern crate {{USER_CRATE}} as _bench_user_crate;
311
312// Re-export mobench-sdk types with UniFFI annotations
313#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
314pub struct BenchSpec {
315    pub name: String,
316    pub iterations: u32,
317    pub warmup: u32,
318}
319
320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
321pub struct BenchSample {
322    pub duration_ns: u64,
323    pub cpu_time_ms: Option<u64>,
324    pub peak_memory_kb: Option<u64>,
325    pub process_peak_memory_kb: Option<u64>,
326}
327
328#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
329pub struct SemanticPhase {
330    pub name: String,
331    pub duration_ns: u64,
332}
333
334#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
335pub struct HarnessTimelineSpan {
336    pub phase: String,
337    pub start_offset_ns: u64,
338    pub end_offset_ns: u64,
339    pub iteration: Option<u32>,
340}
341
342#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
343pub struct BenchReport {
344    pub spec: BenchSpec,
345    pub samples: Vec<BenchSample>,
346    pub phases: Vec<SemanticPhase>,
347    pub timeline: Vec<HarnessTimelineSpan>,
348}
349
350#[derive(Debug, thiserror::Error, uniffi::Error)]
351#[uniffi(flat_error)]
352pub enum BenchError {
353    #[error("iterations must be greater than zero")]
354    InvalidIterations,
355
356    #[error("unknown benchmark function: {name}")]
357    UnknownFunction { name: String },
358
359    #[error("benchmark execution failed: {reason}")]
360    ExecutionFailed { reason: String },
361}
362
363// Convert from mobench-sdk types
364impl From<mobench_sdk::BenchSpec> for BenchSpec {
365    fn from(spec: mobench_sdk::BenchSpec) -> Self {
366        Self {
367            name: spec.name,
368            iterations: spec.iterations,
369            warmup: spec.warmup,
370        }
371    }
372}
373
374impl From<BenchSpec> for mobench_sdk::BenchSpec {
375    fn from(spec: BenchSpec) -> Self {
376        Self {
377            name: spec.name,
378            iterations: spec.iterations,
379            warmup: spec.warmup,
380        }
381    }
382}
383
384impl From<mobench_sdk::BenchSample> for BenchSample {
385    fn from(sample: mobench_sdk::BenchSample) -> Self {
386        Self {
387            duration_ns: sample.duration_ns,
388            cpu_time_ms: sample.cpu_time_ms,
389            peak_memory_kb: sample.peak_memory_kb,
390            process_peak_memory_kb: sample.process_peak_memory_kb,
391        }
392    }
393}
394
395impl From<mobench_sdk::SemanticPhase> for SemanticPhase {
396    fn from(phase: mobench_sdk::SemanticPhase) -> Self {
397        Self {
398            name: phase.name,
399            duration_ns: phase.duration_ns,
400        }
401    }
402}
403
404impl From<mobench_sdk::HarnessTimelineSpan> for HarnessTimelineSpan {
405    fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self {
406        Self {
407            phase: span.phase,
408            start_offset_ns: span.start_offset_ns,
409            end_offset_ns: span.end_offset_ns,
410            iteration: span.iteration,
411        }
412    }
413}
414
415impl From<mobench_sdk::RunnerReport> for BenchReport {
416    fn from(report: mobench_sdk::RunnerReport) -> Self {
417        Self {
418            spec: report.spec.into(),
419            samples: report.samples.into_iter().map(Into::into).collect(),
420            phases: report.phases.into_iter().map(Into::into).collect(),
421            timeline: report.timeline.into_iter().map(Into::into).collect(),
422        }
423    }
424}
425
426impl From<mobench_sdk::BenchError> for BenchError {
427    fn from(err: mobench_sdk::BenchError) -> Self {
428        match err {
429            mobench_sdk::BenchError::Runner(runner_err) => {
430                BenchError::ExecutionFailed {
431                    reason: runner_err.to_string(),
432                }
433            }
434            mobench_sdk::BenchError::UnknownFunction(name, _available) => {
435                BenchError::UnknownFunction { name }
436            }
437            _ => BenchError::ExecutionFailed {
438                reason: err.to_string(),
439            },
440        }
441    }
442}
443
444/// Runs a benchmark by name with the given specification
445///
446/// This is the main FFI entry point called from mobile platforms.
447#[uniffi::export]
448pub fn run_benchmark(spec: BenchSpec) -> Result<BenchReport, BenchError> {
449    let sdk_spec: mobench_sdk::BenchSpec = spec.into();
450    let report = mobench_sdk::run_benchmark(sdk_spec)?;
451    Ok(report.into())
452}
453
454// Generate UniFFI scaffolding
455uniffi::setup_scaffolding!();
456"#;
457
458    let lib_rs = render_template(
459        lib_rs_template,
460        &[TemplateVar {
461            name: "USER_CRATE",
462            value: project_name.replace('-', "_"),
463        }],
464    );
465    fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
466
467    // Generate build.rs
468    let build_rs = r#"fn main() {
469    uniffi::generate_scaffolding("src/lib.rs").unwrap();
470}
471"#;
472
473    fs::write(crate_dir.join("build.rs"), build_rs)?;
474
475    // Generate uniffi-bindgen binary (used by mobench build)
476    let bin_dir = crate_dir.join("src/bin");
477    fs::create_dir_all(&bin_dir)?;
478    let uniffi_bindgen_rs = r#"fn main() {
479    uniffi::uniffi_bindgen_main()
480}
481"#;
482    fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?;
483
484    Ok(())
485}
486
487/// Generates Android project structure from templates
488///
489/// This function can be called standalone to generate just the Android
490/// project scaffolding, useful for auto-generation during build.
491///
492/// # Arguments
493///
494/// * `output_dir` - Directory to write the `android/` project into
495/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
496/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
497pub fn generate_android_project(
498    output_dir: &Path,
499    project_slug: &str,
500    default_function: &str,
501) -> Result<(), BenchError> {
502    let target_dir = output_dir.join("android");
503    reset_generated_project_dir(&target_dir)?;
504    let library_name = project_slug.replace('-', "_");
505    let project_pascal = to_pascal_case(project_slug);
506    // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS
507    // This ensures both platforms use the same naming convention: "benchmobile" not "bench-mobile"
508    let package_id_component = sanitize_bundle_id_component(project_slug);
509    let package_name = format!("dev.world.{}", package_id_component);
510    let vars = vec![
511        TemplateVar {
512            name: "PROJECT_NAME",
513            value: project_slug.to_string(),
514        },
515        TemplateVar {
516            name: "PROJECT_NAME_PASCAL",
517            value: project_pascal.clone(),
518        },
519        TemplateVar {
520            name: "APP_NAME",
521            value: format!("{} Benchmark", project_pascal),
522        },
523        TemplateVar {
524            name: "PACKAGE_NAME",
525            value: package_name.clone(),
526        },
527        TemplateVar {
528            name: "UNIFFI_NAMESPACE",
529            value: library_name.clone(),
530        },
531        TemplateVar {
532            name: "LIBRARY_NAME",
533            value: library_name,
534        },
535        TemplateVar {
536            name: "DEFAULT_FUNCTION",
537            value: default_function.to_string(),
538        },
539    ];
540    render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?;
541
542    // Move Kotlin files to the correct package directory structure
543    // The package "dev.world.{project_slug}" maps to directory "dev/world/{project_slug}/"
544    move_kotlin_files_to_package_dir(&target_dir, &package_name)?;
545
546    Ok(())
547}
548
549fn collect_preserved_files(
550    root: &Path,
551    current: &Path,
552    preserved: &mut Vec<(PathBuf, Vec<u8>)>,
553) -> Result<(), BenchError> {
554    let mut entries = fs::read_dir(current)?
555        .collect::<Result<Vec<_>, _>>()
556        .map_err(BenchError::Io)?;
557    entries.sort_by_key(|entry| entry.path());
558
559    for entry in entries {
560        let path = entry.path();
561        if path.is_dir() {
562            collect_preserved_files(root, &path, preserved)?;
563            continue;
564        }
565
566        let relative = path.strip_prefix(root).map_err(|e| {
567            BenchError::Build(format!(
568                "Failed to preserve generated resource {:?}: {}",
569                path, e
570            ))
571        })?;
572        preserved.push((relative.to_path_buf(), fs::read(&path)?));
573    }
574
575    Ok(())
576}
577
578fn collect_preserved_ios_resources(
579    target_dir: &Path,
580) -> Result<Vec<(PathBuf, Vec<u8>)>, BenchError> {
581    let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
582    let mut preserved = Vec::new();
583
584    if resources_dir.exists() {
585        collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?;
586    }
587
588    Ok(preserved)
589}
590
591fn restore_preserved_ios_resources(
592    target_dir: &Path,
593    preserved_resources: &[(PathBuf, Vec<u8>)],
594) -> Result<(), BenchError> {
595    if preserved_resources.is_empty() {
596        return Ok(());
597    }
598
599    let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources");
600    for (relative, contents) in preserved_resources {
601        let resource_path = resources_dir.join(relative);
602        if let Some(parent) = resource_path.parent() {
603            fs::create_dir_all(parent)?;
604        }
605        fs::write(resource_path, contents)?;
606    }
607
608    Ok(())
609}
610
611fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> {
612    if target_dir.exists() {
613        fs::remove_dir_all(target_dir).map_err(|e| {
614            BenchError::Build(format!(
615                "Failed to clear existing generated project at {:?}: {}",
616                target_dir, e
617            ))
618        })?;
619    }
620    Ok(())
621}
622
623/// Moves Kotlin source files to the correct package directory structure
624///
625/// Android requires source files to be in directories matching their package declaration.
626/// For example, a file with `package dev.world.my_project` must be in
627/// `app/src/main/java/dev/world/my_project/`.
628///
629/// This function moves:
630/// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/`
631/// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/`
632fn move_kotlin_files_to_package_dir(
633    android_dir: &Path,
634    package_name: &str,
635) -> Result<(), BenchError> {
636    // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project")
637    let package_path = package_name.replace('.', "/");
638
639    // Move main source files
640    let main_java_dir = android_dir.join("app/src/main/java");
641    let main_package_dir = main_java_dir.join(&package_path);
642    move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?;
643
644    // Move test source files
645    let test_java_dir = android_dir.join("app/src/androidTest/java");
646    let test_package_dir = test_java_dir.join(&package_path);
647    move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?;
648
649    Ok(())
650}
651
652/// Moves a single Kotlin file from source directory to package directory
653fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> {
654    let src_file = src_dir.join(filename);
655    if !src_file.exists() {
656        // File doesn't exist in source, nothing to move
657        return Ok(());
658    }
659
660    // Create the package directory if it doesn't exist
661    fs::create_dir_all(dest_dir).map_err(|e| {
662        BenchError::Build(format!(
663            "Failed to create package directory {:?}: {}",
664            dest_dir, e
665        ))
666    })?;
667
668    let dest_file = dest_dir.join(filename);
669
670    // Move the file (copy + delete for cross-filesystem compatibility)
671    fs::copy(&src_file, &dest_file).map_err(|e| {
672        BenchError::Build(format!(
673            "Failed to copy {} to {:?}: {}",
674            filename, dest_file, e
675        ))
676    })?;
677
678    fs::remove_file(&src_file).map_err(|e| {
679        BenchError::Build(format!(
680            "Failed to remove original file {:?}: {}",
681            src_file, e
682        ))
683    })?;
684
685    Ok(())
686}
687
688/// Generates iOS project structure from templates
689///
690/// This function can be called standalone to generate just the iOS
691/// project scaffolding, useful for auto-generation during build.
692///
693/// # Arguments
694///
695/// * `output_dir` - Directory to write the `ios/` project into
696/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile")
697/// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile")
698/// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench")
699/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark")
700pub fn generate_ios_project(
701    output_dir: &Path,
702    project_slug: &str,
703    project_pascal: &str,
704    bundle_prefix: &str,
705    default_function: &str,
706) -> Result<(), BenchError> {
707    let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs(
708        std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
709            .ok()
710            .as_deref(),
711    );
712    generate_ios_project_with_options(
713        output_dir,
714        project_slug,
715        project_pascal,
716        bundle_prefix,
717        default_function,
718        IosProjectOptions {
719            ios_benchmark_timeout_secs,
720            ..IosProjectOptions::default()
721        },
722    )
723}
724
725#[cfg(test)]
726#[allow(dead_code)]
727fn generate_ios_project_with_timeout(
728    output_dir: &Path,
729    project_slug: &str,
730    project_pascal: &str,
731    bundle_prefix: &str,
732    default_function: &str,
733    ios_benchmark_timeout_secs: u64,
734) -> Result<(), BenchError> {
735    generate_ios_project_with_options(
736        output_dir,
737        project_slug,
738        project_pascal,
739        bundle_prefix,
740        default_function,
741        IosProjectOptions {
742            ios_benchmark_timeout_secs,
743            ..IosProjectOptions::default()
744        },
745    )
746}
747
748pub fn generate_ios_project_with_options(
749    output_dir: &Path,
750    project_slug: &str,
751    project_pascal: &str,
752    bundle_prefix: &str,
753    default_function: &str,
754    options: IosProjectOptions,
755) -> Result<(), BenchError> {
756    let runner = resolve_ios_runner(&options.deployment_target, Some(options.runner))?;
757    let target_dir = output_dir.join("ios");
758    let preserved_resources = collect_preserved_ios_resources(&target_dir)?;
759    reset_generated_project_dir(&target_dir)?;
760    // Sanitize bundle ID components to ensure they only contain alphanumeric characters
761    // iOS bundle identifiers should not contain hyphens or underscores
762    let sanitized_bundle_prefix = {
763        let parts: Vec<&str> = bundle_prefix.split('.').collect();
764        parts
765            .iter()
766            .map(|part| sanitize_bundle_id_component(part))
767            .collect::<Vec<_>>()
768            .join(".")
769    };
770    // Use the actual app name (project_pascal, e.g., "BenchRunner") for the bundle ID suffix,
771    // not the crate name again. This prevents duplication like "dev.world.benchmobile.benchmobile"
772    // and produces the correct "dev.world.benchmobile.BenchRunner"
773    let vars = vec![
774        TemplateVar {
775            name: "DEFAULT_FUNCTION",
776            value: default_function.to_string(),
777        },
778        TemplateVar {
779            name: "PROJECT_NAME_PASCAL",
780            value: project_pascal.to_string(),
781        },
782        TemplateVar {
783            name: "BUNDLE_ID_PREFIX",
784            value: sanitized_bundle_prefix.clone(),
785        },
786        TemplateVar {
787            name: "BUNDLE_ID",
788            value: format!("{}.{}", sanitized_bundle_prefix, project_pascal),
789        },
790        TemplateVar {
791            name: "LIBRARY_NAME",
792            value: project_slug.replace('-', "_"),
793        },
794        TemplateVar {
795            name: "IOS_BENCHMARK_TIMEOUT_SECS",
796            value: options.ios_benchmark_timeout_secs.to_string(),
797        },
798        TemplateVar {
799            name: "IOS_DEPLOYMENT_TARGET",
800            value: options.deployment_target.to_string(),
801        },
802        TemplateVar {
803            name: "IOS_RUNNER",
804            value: runner.as_str().to_string(),
805        },
806    ];
807    render_ios_dir(&IOS_TEMPLATES, &target_dir, &vars, runner)?;
808    restore_preserved_ios_resources(&target_dir, &preserved_resources)?;
809    Ok(())
810}
811
812fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 {
813    value
814        .and_then(|raw| raw.parse::<u64>().ok())
815        .filter(|secs| *secs > 0)
816        .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
817}
818
819/// Generates bench-config.toml configuration file
820fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> {
821    let config_target = match config.target {
822        Target::Ios => "ios",
823        Target::Android | Target::Both => "android",
824    };
825    let config_content = format!(
826        r#"# mobench configuration
827# This file controls how benchmarks are executed on devices.
828
829target = "{}"
830function = "example_fibonacci"
831iterations = 100
832warmup = 10
833device_matrix = "device-matrix.yaml"
834device_tags = ["default"]
835
836[browserstack]
837app_automate_username = "${{BROWSERSTACK_USERNAME}}"
838app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}"
839project = "{}-benchmarks"
840
841[ios_xcuitest]
842app = "target/ios/BenchRunner.ipa"
843test_suite = "target/ios/BenchRunnerUITests.zip"
844"#,
845        config_target, config.project_name
846    );
847
848    fs::write(output_dir.join("bench-config.toml"), config_content)?;
849
850    Ok(())
851}
852
853/// Generates example benchmark functions
854fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> {
855    let examples_dir = output_dir.join("benches");
856    fs::create_dir_all(&examples_dir)?;
857
858    let example_content = r#"//! Example benchmarks
859//!
860//! This file demonstrates how to write benchmarks with mobench-sdk.
861
862use mobench_sdk::benchmark;
863
864/// Simple benchmark example
865#[benchmark]
866fn example_fibonacci() {
867    let result = fibonacci(30);
868    std::hint::black_box(result);
869}
870
871/// Another example with a loop
872#[benchmark]
873fn example_sum() {
874    let mut sum = 0u64;
875    for i in 0..10000 {
876        sum = sum.wrapping_add(i);
877    }
878    std::hint::black_box(sum);
879}
880
881// Helper function (not benchmarked)
882fn fibonacci(n: u32) -> u64 {
883    match n {
884        0 => 0,
885        1 => 1,
886        _ => {
887            let mut a = 0u64;
888            let mut b = 1u64;
889            for _ in 2..=n {
890                let next = a.wrapping_add(b);
891                a = b;
892                b = next;
893            }
894            b
895        }
896    }
897}
898"#;
899
900    fs::write(examples_dir.join("example.rs"), example_content)?;
901
902    Ok(())
903}
904
905/// File extensions that should be processed for template variable substitution
906const TEMPLATE_EXTENSIONS: &[&str] = &[
907    "gradle",
908    "xml",
909    "kt",
910    "java",
911    "swift",
912    "yml",
913    "yaml",
914    "json",
915    "toml",
916    "md",
917    "txt",
918    "h",
919    "m",
920    "plist",
921    "pbxproj",
922    "xcscheme",
923    "xcworkspacedata",
924    "entitlements",
925    "modulemap",
926];
927
928fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> {
929    render_dir_filtered(dir, out_root, vars, &|_| false)
930}
931
932fn render_ios_dir(
933    dir: &Dir,
934    out_root: &Path,
935    vars: &[TemplateVar],
936    runner: IosRunner,
937) -> Result<(), BenchError> {
938    render_dir_filtered(dir, out_root, vars, &|path| match runner {
939        IosRunner::Swiftui => {
940            path == Path::new("BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template")
941        }
942        IosRunner::UikitLegacy => {
943            path == Path::new("BenchRunner/BenchRunner/BenchRunnerApp.swift.template")
944                || path == Path::new("BenchRunner/BenchRunner/ContentView.swift.template")
945        }
946    })
947}
948
949fn render_dir_filtered(
950    dir: &Dir,
951    out_root: &Path,
952    vars: &[TemplateVar],
953    skip_file: &dyn Fn(&Path) -> bool,
954) -> Result<(), BenchError> {
955    for entry in dir.entries() {
956        match entry {
957            DirEntry::Dir(sub) => {
958                // Skip cache directories
959                if sub.path().components().any(|c| c.as_os_str() == ".gradle") {
960                    continue;
961                }
962                render_dir_filtered(sub, out_root, vars, skip_file)?;
963            }
964            DirEntry::File(file) => {
965                if file.path().components().any(|c| c.as_os_str() == ".gradle") {
966                    continue;
967                }
968                if skip_file(file.path()) {
969                    continue;
970                }
971                // file.path() returns the full relative path from the embedded dir root
972                let mut relative = file.path().to_path_buf();
973                let mut contents = file.contents().to_vec();
974
975                // Check if file has .template extension (explicit template)
976                let is_explicit_template = relative
977                    .extension()
978                    .map(|ext| ext == "template")
979                    .unwrap_or(false);
980
981                // Check if file is a text file that should be processed for templates
982                let should_render = is_explicit_template || is_template_file(&relative);
983
984                if is_explicit_template {
985                    // Remove .template extension from output filename
986                    relative.set_extension("");
987                }
988
989                if should_render && let Ok(text) = std::str::from_utf8(&contents) {
990                    let rendered = render_template(text, vars);
991                    // Validate that all template variables were replaced
992                    validate_no_unreplaced_placeholders(&rendered, &relative)?;
993                    contents = rendered.into_bytes();
994                }
995
996                let out_path = out_root.join(relative);
997                if let Some(parent) = out_path.parent() {
998                    fs::create_dir_all(parent)?;
999                }
1000                fs::write(&out_path, contents)?;
1001            }
1002        }
1003    }
1004    Ok(())
1005}
1006
1007/// Checks if a file should be processed for template variable substitution
1008/// based on its extension
1009fn is_template_file(path: &Path) -> bool {
1010    // Check for .template extension on any file
1011    if let Some(ext) = path.extension() {
1012        if ext == "template" {
1013            return true;
1014        }
1015        // Check if the base extension is in our list
1016        if let Some(ext_str) = ext.to_str() {
1017            return TEMPLATE_EXTENSIONS.contains(&ext_str);
1018        }
1019    }
1020    // Also check the filename without the .template extension
1021    if let Some(stem) = path.file_stem() {
1022        let stem_path = Path::new(stem);
1023        if let Some(ext) = stem_path.extension()
1024            && let Some(ext_str) = ext.to_str()
1025        {
1026            return TEMPLATE_EXTENSIONS.contains(&ext_str);
1027        }
1028    }
1029    false
1030}
1031
1032/// Validates that no unreplaced template placeholders remain in the rendered content
1033fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> {
1034    // Find all {{...}} patterns
1035    let mut pos = 0;
1036    let mut unreplaced = Vec::new();
1037
1038    while let Some(start) = content[pos..].find("{{") {
1039        let abs_start = pos + start;
1040        if let Some(end) = content[abs_start..].find("}}") {
1041            let placeholder = &content[abs_start..abs_start + end + 2];
1042            // Extract just the variable name
1043            let var_name = &content[abs_start + 2..abs_start + end];
1044            // Skip placeholders that look like Gradle variable syntax (e.g., ${...})
1045            // or other non-template patterns
1046            if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() {
1047                unreplaced.push(placeholder.to_string());
1048            }
1049            pos = abs_start + end + 2;
1050        } else {
1051            break;
1052        }
1053    }
1054
1055    if !unreplaced.is_empty() {
1056        return Err(BenchError::Build(format!(
1057            "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\
1058             This is a bug in mobench-sdk. Please report it at:\n\
1059             https://github.com/worldcoin/mobile-bench-rs/issues",
1060            file_path, unreplaced
1061        )));
1062    }
1063
1064    Ok(())
1065}
1066
1067fn render_template(input: &str, vars: &[TemplateVar]) -> String {
1068    let mut output = input.to_string();
1069    for var in vars {
1070        output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value);
1071    }
1072    output
1073}
1074
1075/// Sanitizes a string to be a valid iOS bundle identifier component
1076///
1077/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9),
1078/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency,
1079/// this function converts all non-alphanumeric characters to lowercase letters only.
1080///
1081/// Examples:
1082/// - "bench-mobile" -> "benchmobile"
1083/// - "bench_mobile" -> "benchmobile"
1084/// - "my-project_name" -> "myprojectname"
1085pub fn sanitize_bundle_id_component(name: &str) -> String {
1086    name.chars()
1087        .filter(|c| c.is_ascii_alphanumeric())
1088        .collect::<String>()
1089        .to_lowercase()
1090}
1091
1092fn sanitize_package_name(name: &str) -> String {
1093    name.chars()
1094        .map(|c| {
1095            if c.is_ascii_alphanumeric() {
1096                c.to_ascii_lowercase()
1097            } else {
1098                '-'
1099            }
1100        })
1101        .collect::<String>()
1102        .trim_matches('-')
1103        .replace("--", "-")
1104}
1105
1106/// Converts a string to PascalCase
1107pub fn to_pascal_case(input: &str) -> String {
1108    input
1109        .split(|c: char| !c.is_ascii_alphanumeric())
1110        .filter(|s| !s.is_empty())
1111        .map(|s| {
1112            let mut chars = s.chars();
1113            let first = chars.next().unwrap().to_ascii_uppercase();
1114            let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
1115            format!("{}{}", first, rest)
1116        })
1117        .collect::<String>()
1118}
1119
1120/// Checks if the Android project scaffolding exists at the given output directory
1121///
1122/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists.
1123pub fn android_project_exists(output_dir: &Path) -> bool {
1124    let android_dir = output_dir.join("android");
1125    android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists()
1126}
1127
1128/// Checks if the iOS project scaffolding exists at the given output directory
1129///
1130/// Returns true if the `ios/BenchRunner/project.yml` file exists.
1131pub fn ios_project_exists(output_dir: &Path) -> bool {
1132    output_dir.join("ios/BenchRunner/project.yml").exists()
1133}
1134
1135/// Checks whether an existing iOS project was generated for the given library name.
1136///
1137/// Returns `false` if the xcframework reference in `project.yml` doesn't match,
1138/// which means the project needs to be regenerated for the new crate.
1139fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1140    let project_yml = output_dir.join("ios/BenchRunner/project.yml");
1141    let Ok(content) = std::fs::read_to_string(&project_yml) else {
1142        return false;
1143    };
1144    let expected = format!("../{}.xcframework", library_name);
1145    content.contains(&expected)
1146}
1147
1148/// Checks whether an existing Android project was generated for the given library name.
1149///
1150/// Returns `false` if the JNI library name in `build.gradle` doesn't match,
1151/// which means the project needs to be regenerated for the new crate.
1152fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool {
1153    let build_gradle = output_dir.join("android/app/build.gradle");
1154    let Ok(content) = std::fs::read_to_string(&build_gradle) else {
1155        return false;
1156    };
1157    let expected = format!("lib{}.so", library_name);
1158    content.contains(&expected)
1159}
1160
1161/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]`
1162///
1163/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1164/// the first one found in the format `{crate_name}::{function_name}`.
1165///
1166/// # Arguments
1167///
1168/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1169/// * `crate_name` - Name of the crate (used as prefix for the function name)
1170///
1171/// # Returns
1172///
1173/// * `Some(String)` - The detected function name in format `crate_name::function_name`
1174/// * `None` - If no benchmark functions are found or if the file cannot be read
1175pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option<String> {
1176    let lib_rs = crate_dir.join("src/lib.rs");
1177    if !lib_rs.exists() {
1178        return None;
1179    }
1180
1181    let file = fs::File::open(&lib_rs).ok()?;
1182    let reader = BufReader::new(file);
1183
1184    let mut found_benchmark_attr = false;
1185    let crate_name_normalized = crate_name.replace('-', "_");
1186
1187    for line in reader.lines().map_while(Result::ok) {
1188        let trimmed = line.trim();
1189
1190        // Check for #[benchmark] attribute
1191        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1192            found_benchmark_attr = true;
1193            continue;
1194        }
1195
1196        // If we found a benchmark attribute, look for the function definition
1197        if found_benchmark_attr {
1198            // Look for "fn function_name" or "pub fn function_name"
1199            if let Some(fn_pos) = trimmed.find("fn ") {
1200                let after_fn = &trimmed[fn_pos + 3..];
1201                // Extract function name (until '(' or whitespace)
1202                let fn_name: String = after_fn
1203                    .chars()
1204                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1205                    .collect();
1206
1207                if !fn_name.is_empty() {
1208                    return Some(format!("{}::{}", crate_name_normalized, fn_name));
1209                }
1210            }
1211            // Reset if we hit a line that's not a function definition
1212            // (could be another attribute or comment)
1213            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1214                found_benchmark_attr = false;
1215            }
1216        }
1217    }
1218
1219    None
1220}
1221
1222/// Detects all benchmark functions in a crate by scanning src/lib.rs for `#[benchmark]`
1223///
1224/// This function looks for functions marked with the `#[benchmark]` attribute and returns
1225/// all found in the format `{crate_name}::{function_name}`.
1226///
1227/// # Arguments
1228///
1229/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1230/// * `crate_name` - Name of the crate (used as prefix for the function names)
1231///
1232/// # Returns
1233///
1234/// A vector of benchmark function names in format `crate_name::function_name`
1235pub fn detect_all_benchmarks(crate_dir: &Path, crate_name: &str) -> Vec<String> {
1236    let lib_rs = crate_dir.join("src/lib.rs");
1237    if !lib_rs.exists() {
1238        return Vec::new();
1239    }
1240
1241    let Ok(file) = fs::File::open(&lib_rs) else {
1242        return Vec::new();
1243    };
1244    let reader = BufReader::new(file);
1245
1246    let mut benchmarks = Vec::new();
1247    let mut found_benchmark_attr = false;
1248    let crate_name_normalized = crate_name.replace('-', "_");
1249
1250    for line in reader.lines().map_while(Result::ok) {
1251        let trimmed = line.trim();
1252
1253        // Check for #[benchmark] attribute
1254        if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") {
1255            found_benchmark_attr = true;
1256            continue;
1257        }
1258
1259        // If we found a benchmark attribute, look for the function definition
1260        if found_benchmark_attr {
1261            // Look for "fn function_name" or "pub fn function_name"
1262            if let Some(fn_pos) = trimmed.find("fn ") {
1263                let after_fn = &trimmed[fn_pos + 3..];
1264                // Extract function name (until '(' or whitespace)
1265                let fn_name: String = after_fn
1266                    .chars()
1267                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1268                    .collect();
1269
1270                if !fn_name.is_empty() {
1271                    benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name));
1272                }
1273                found_benchmark_attr = false;
1274            }
1275            // Reset if we hit a line that's not a function definition
1276            // (could be another attribute or comment)
1277            if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() {
1278                found_benchmark_attr = false;
1279            }
1280        }
1281    }
1282
1283    benchmarks
1284}
1285
1286/// Validates that a benchmark function exists in the crate source
1287///
1288/// # Arguments
1289///
1290/// * `crate_dir` - Path to the crate directory containing Cargo.toml
1291/// * `crate_name` - Name of the crate (used as prefix for the function names)
1292/// * `function_name` - The function name to validate (with or without crate prefix)
1293///
1294/// # Returns
1295///
1296/// `true` if the function is found, `false` otherwise
1297pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool {
1298    let benchmarks = detect_all_benchmarks(crate_dir, crate_name);
1299    let crate_name_normalized = crate_name.replace('-', "_");
1300
1301    // Normalize the function name - add crate prefix if missing
1302    let normalized_name = if function_name.contains("::") {
1303        function_name.to_string()
1304    } else {
1305        format!("{}::{}", crate_name_normalized, function_name)
1306    };
1307
1308    benchmarks.iter().any(|b| b == &normalized_name)
1309}
1310
1311/// Resolves the default benchmark function for a project
1312///
1313/// This function attempts to auto-detect benchmark functions from the crate's source.
1314/// If no benchmarks are found, it falls back to a sensible default based on the crate name.
1315///
1316/// # Arguments
1317///
1318/// * `project_root` - Root directory of the project
1319/// * `crate_name` - Name of the benchmark crate
1320/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations)
1321///
1322/// # Returns
1323///
1324/// The default function name in format `crate_name::function_name`
1325pub fn resolve_default_function(
1326    project_root: &Path,
1327    crate_name: &str,
1328    crate_dir: Option<&Path>,
1329) -> String {
1330    let crate_name_normalized = crate_name.replace('-', "_");
1331
1332    // Try to find the crate directory
1333    let search_dirs: Vec<PathBuf> = if let Some(dir) = crate_dir {
1334        vec![dir.to_path_buf()]
1335    } else {
1336        vec![
1337            project_root.join("bench-mobile"),
1338            project_root.join("crates").join(crate_name),
1339            project_root.to_path_buf(),
1340        ]
1341    };
1342
1343    // Try to detect benchmarks from each potential location
1344    for dir in &search_dirs {
1345        if dir.join("Cargo.toml").exists()
1346            && let Some(detected) = detect_default_function(dir, &crate_name_normalized)
1347        {
1348            return detected;
1349        }
1350    }
1351
1352    // Fallback: use a sensible default based on crate name
1353    format!("{}::example_benchmark", crate_name_normalized)
1354}
1355
1356/// Auto-generates Android project scaffolding from a crate name
1357///
1358/// This is a convenience function that derives template variables from the
1359/// crate name and generates the Android project structure. It auto-detects
1360/// the default benchmark function from the crate's source code.
1361///
1362/// # Arguments
1363///
1364/// * `output_dir` - Directory to write the `android/` project into
1365/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1366pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1367    ensure_android_project_with_options(output_dir, crate_name, None, None)
1368}
1369
1370/// Auto-generates Android project scaffolding with additional options
1371///
1372/// This is a more flexible version of `ensure_android_project` that allows
1373/// specifying a custom default function and/or crate directory.
1374///
1375/// # Arguments
1376///
1377/// * `output_dir` - Directory to write the `android/` project into
1378/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1379/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1380/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1381pub fn ensure_android_project_with_options(
1382    output_dir: &Path,
1383    crate_name: &str,
1384    project_root: Option<&Path>,
1385    crate_dir: Option<&Path>,
1386) -> Result<(), BenchError> {
1387    let library_name = crate_name.replace('-', "_");
1388    if android_project_exists(output_dir)
1389        && android_project_matches_library(output_dir, &library_name)
1390    {
1391        return Ok(());
1392    }
1393
1394    println!("Android project not found, generating scaffolding...");
1395    let project_slug = crate_name.replace('-', "_");
1396
1397    // Resolve the default function by auto-detecting from source
1398    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1399    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1400
1401    generate_android_project(output_dir, &project_slug, &default_function)?;
1402    println!(
1403        "  Generated Android project at {:?}",
1404        output_dir.join("android")
1405    );
1406    println!("  Default benchmark function: {}", default_function);
1407    Ok(())
1408}
1409
1410/// Auto-generates iOS project scaffolding from a crate name
1411///
1412/// This is a convenience function that derives template variables from the
1413/// crate name and generates the iOS project structure. It auto-detects
1414/// the default benchmark function from the crate's source code.
1415///
1416/// # Arguments
1417///
1418/// * `output_dir` - Directory to write the `ios/` project into
1419/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1420pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> {
1421    ensure_ios_project_with_options(output_dir, crate_name, None, None)
1422}
1423
1424/// Auto-generates iOS project scaffolding with additional options
1425///
1426/// This is a more flexible version of `ensure_ios_project` that allows
1427/// specifying a custom default function and/or crate directory.
1428///
1429/// # Arguments
1430///
1431/// * `output_dir` - Directory to write the `ios/` project into
1432/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile")
1433/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent)
1434/// * `crate_dir` - Optional explicit crate directory for benchmark detection
1435pub fn ensure_ios_project_with_options(
1436    output_dir: &Path,
1437    crate_name: &str,
1438    project_root: Option<&Path>,
1439    crate_dir: Option<&Path>,
1440) -> Result<(), BenchError> {
1441    ensure_ios_project_with_project_options(
1442        output_dir,
1443        crate_name,
1444        project_root,
1445        crate_dir,
1446        IosProjectOptions::default(),
1447    )
1448}
1449
1450pub fn ensure_ios_project_with_project_options(
1451    output_dir: &Path,
1452    crate_name: &str,
1453    project_root: Option<&Path>,
1454    crate_dir: Option<&Path>,
1455    options: IosProjectOptions,
1456) -> Result<(), BenchError> {
1457    let library_name = crate_name.replace('-', "_");
1458    let project_exists = ios_project_exists(output_dir);
1459    let project_matches = ios_project_matches_library(output_dir, &library_name);
1460    if project_exists && !project_matches {
1461        println!("Existing iOS scaffolding does not match library, regenerating...");
1462    } else if project_exists {
1463        println!("Refreshing generated iOS scaffolding...");
1464    } else {
1465        println!("iOS project not found, generating scaffolding...");
1466    }
1467
1468    // Use fixed "BenchRunner" for project/scheme name to match template directory structure
1469    let project_pascal = "BenchRunner";
1470    // Derive library name and bundle prefix from crate name
1471    let library_name = crate_name.replace('-', "_");
1472    // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues
1473    // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile"
1474    let bundle_id_component = sanitize_bundle_id_component(crate_name);
1475    let bundle_prefix = format!("dev.world.{}", bundle_id_component);
1476
1477    // Resolve the default function by auto-detecting from source
1478    let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir));
1479    let default_function = resolve_default_function(effective_root, crate_name, crate_dir);
1480
1481    generate_ios_project_with_options(
1482        output_dir,
1483        &library_name,
1484        project_pascal,
1485        &bundle_prefix,
1486        &default_function,
1487        options,
1488    )?;
1489    println!("  Generated iOS project at {:?}", output_dir.join("ios"));
1490    println!("  Default benchmark function: {}", default_function);
1491    Ok(())
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496    use super::*;
1497    use std::env;
1498
1499    #[test]
1500    fn test_generate_bench_mobile_crate() {
1501        let temp_dir = env::temp_dir().join("mobench-sdk-test");
1502        fs::create_dir_all(&temp_dir).unwrap();
1503
1504        let result = generate_bench_mobile_crate(&temp_dir, "test_project");
1505        assert!(result.is_ok());
1506
1507        // Verify files were created
1508        assert!(temp_dir.join("bench-mobile/Cargo.toml").exists());
1509        assert!(temp_dir.join("bench-mobile/src/lib.rs").exists());
1510        assert!(temp_dir.join("bench-mobile/build.rs").exists());
1511        let cargo_toml =
1512            fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml");
1513        assert!(
1514            cargo_toml.contains(
1515                r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"#
1516            ),
1517            "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}"
1518        );
1519
1520        // Cleanup
1521        fs::remove_dir_all(&temp_dir).ok();
1522    }
1523
1524    #[test]
1525    fn test_generate_android_project_no_unreplaced_placeholders() {
1526        let temp_dir = env::temp_dir().join("mobench-sdk-android-test");
1527        // Clean up any previous test run
1528        let _ = fs::remove_dir_all(&temp_dir);
1529        fs::create_dir_all(&temp_dir).unwrap();
1530
1531        let result =
1532            generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func");
1533        assert!(
1534            result.is_ok(),
1535            "generate_android_project failed: {:?}",
1536            result.err()
1537        );
1538
1539        // Verify key files exist
1540        let android_dir = temp_dir.join("android");
1541        assert!(android_dir.join("settings.gradle").exists());
1542        assert!(android_dir.join("app/build.gradle").exists());
1543        assert!(
1544            android_dir
1545                .join("app/src/main/AndroidManifest.xml")
1546                .exists()
1547        );
1548        assert!(
1549            android_dir
1550                .join("app/src/main/res/values/strings.xml")
1551                .exists()
1552        );
1553        assert!(
1554            android_dir
1555                .join("app/src/main/res/values/themes.xml")
1556                .exists()
1557        );
1558
1559        // Verify no unreplaced placeholders remain in generated files
1560        let files_to_check = [
1561            "settings.gradle",
1562            "app/build.gradle",
1563            "app/src/main/AndroidManifest.xml",
1564            "app/src/main/res/values/strings.xml",
1565            "app/src/main/res/values/themes.xml",
1566        ];
1567
1568        for file in files_to_check {
1569            let path = android_dir.join(file);
1570            let contents =
1571                fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file));
1572
1573            // Check for unreplaced placeholders
1574            let has_placeholder = contents.contains("{{") && contents.contains("}}");
1575            assert!(
1576                !has_placeholder,
1577                "File {} contains unreplaced template placeholders: {}",
1578                file, contents
1579            );
1580        }
1581
1582        // Verify specific substitutions were made
1583        let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap();
1584        assert!(
1585            settings.contains("my-bench-project-android")
1586                || settings.contains("my_bench_project-android"),
1587            "settings.gradle should contain project name"
1588        );
1589
1590        let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap();
1591        // Package name should be sanitized (no hyphens/underscores) for consistency with iOS
1592        assert!(
1593            build_gradle.contains("dev.world.mybenchproject"),
1594            "build.gradle should contain sanitized package name 'dev.world.mybenchproject'"
1595        );
1596        assert!(
1597            !build_gradle.contains("testBuildType \"release\""),
1598            "debug builds should be able to produce assembleDebugAndroidTest"
1599        );
1600        assert!(
1601            build_gradle.contains("mobenchTestBuildType"),
1602            "release builds should be able to request assembleReleaseAndroidTest"
1603        );
1604
1605        let manifest =
1606            fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap();
1607        assert!(
1608            manifest.contains("Theme.MyBenchProject"),
1609            "AndroidManifest.xml should contain PascalCase theme name"
1610        );
1611
1612        let strings =
1613            fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap();
1614        assert!(
1615            strings.contains("Benchmark"),
1616            "strings.xml should contain app name with Benchmark"
1617        );
1618
1619        // Verify Kotlin files are in the correct package directory structure
1620        // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/"
1621        let main_activity_path =
1622            android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt");
1623        assert!(
1624            main_activity_path.exists(),
1625            "MainActivity.kt should be in package directory: {:?}",
1626            main_activity_path
1627        );
1628
1629        let test_activity_path = android_dir
1630            .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt");
1631        assert!(
1632            test_activity_path.exists(),
1633            "MainActivityTest.kt should be in package directory: {:?}",
1634            test_activity_path
1635        );
1636
1637        // Verify the files are NOT in the root java directory
1638        assert!(
1639            !android_dir
1640                .join("app/src/main/java/MainActivity.kt")
1641                .exists(),
1642            "MainActivity.kt should not be in root java directory"
1643        );
1644        assert!(
1645            !android_dir
1646                .join("app/src/androidTest/java/MainActivityTest.kt")
1647                .exists(),
1648            "MainActivityTest.kt should not be in root java directory"
1649        );
1650
1651        // Cleanup
1652        fs::remove_dir_all(&temp_dir).ok();
1653    }
1654
1655    #[test]
1656    fn test_generate_android_project_replaces_previous_package_tree() {
1657        let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test");
1658        let _ = fs::remove_dir_all(&temp_dir);
1659        fs::create_dir_all(&temp_dir).unwrap();
1660
1661        generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci")
1662            .unwrap();
1663        let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark");
1664        assert!(
1665            old_package_dir.exists(),
1666            "expected first package tree to exist"
1667        );
1668
1669        generate_android_project(
1670            &temp_dir,
1671            "basic_benchmark",
1672            "basic_benchmark::bench_fibonacci",
1673        )
1674        .unwrap();
1675
1676        let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark");
1677        assert!(
1678            new_package_dir.exists(),
1679            "expected new package tree to exist"
1680        );
1681        assert!(
1682            !old_package_dir.exists(),
1683            "old package tree should be removed when regenerating the Android scaffold"
1684        );
1685
1686        fs::remove_dir_all(&temp_dir).ok();
1687    }
1688
1689    #[test]
1690    fn test_is_template_file() {
1691        assert!(is_template_file(Path::new("settings.gradle")));
1692        assert!(is_template_file(Path::new("app/build.gradle")));
1693        assert!(is_template_file(Path::new("AndroidManifest.xml")));
1694        assert!(is_template_file(Path::new("strings.xml")));
1695        assert!(is_template_file(Path::new("MainActivity.kt.template")));
1696        assert!(is_template_file(Path::new("project.yml")));
1697        assert!(is_template_file(Path::new("Info.plist")));
1698        assert!(!is_template_file(Path::new("libfoo.so")));
1699        assert!(!is_template_file(Path::new("image.png")));
1700    }
1701
1702    #[test]
1703    fn test_mobile_templates_read_process_peak_memory_compatibly() {
1704        let android =
1705            include_str!("../templates/android/app/src/main/java/MainActivity.kt.template");
1706        assert!(
1707            !android.contains("sample.processPeakMemoryKb"),
1708            "Android template should not require generated bindings to expose processPeakMemoryKb"
1709        );
1710        assert!(
1711            !android.contains("it.processPeakMemoryKb"),
1712            "Android template should not require generated bindings to expose processPeakMemoryKb"
1713        );
1714        assert!(android.contains("optionalProcessPeakMemoryKb(sample)"));
1715        assert!(
1716            !android.contains("sample.cpuTimeMs"),
1717            "Android template should tolerate BenchSample without cpuTimeMs"
1718        );
1719        assert!(
1720            !android.contains("sample.peakMemoryKb"),
1721            "Android template should tolerate BenchSample without peakMemoryKb"
1722        );
1723        assert!(
1724            !android.contains("report.phases"),
1725            "Android template should tolerate BenchReport without phases"
1726        );
1727        assert!(android.contains("ProcessMemorySampler"));
1728        assert!(android.contains("sampleIntervalMs: Long = 1000L"));
1729        assert!(android.contains("/proc/self/smaps_rollup"));
1730        assert!(android.contains("class BenchmarkWorkerService : Service()"));
1731        assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))"));
1732        assert!(android.contains("startForegroundService(intent)"));
1733        assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID"));
1734        assert!(android.contains("fun isBenchmarkComplete()"));
1735        assert!(android.contains("BENCH_JSON ${json}"));
1736        assert!(android.contains("BENCH_HEARTBEAT_JSON $json"));
1737        assert!(android.contains("BENCH_FAILURE_JSON $encoded"));
1738        assert!(android.contains("getHistoricalProcessExitReasons"));
1739        assert!(android.contains("ApplicationExitInfo.REASON_LOW_MEMORY"));
1740        assert!(android.contains("android_benchmark_timeout_secs"));
1741        assert!(android.contains("android_heartbeat_interval_secs"));
1742        assert!(!android.contains("resultLatch.await"));
1743        assert!(android.contains("memory_process\", \"isolated_worker\""));
1744
1745        let android_test = include_str!(
1746            "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template"
1747        );
1748        assert!(android_test.contains("Log.i(\"BenchRunnerTest\""));
1749        assert!(android_test.contains("Thread.sleep(pollMs)"));
1750        assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)"));
1751        assert!(android_test.contains("activity.isBenchmarkComplete()"));
1752        assert!(android_test.contains("activity.isBenchmarkFailed()"));
1753        assert!(android_test.contains("activity.emitTimeoutFailureFromTest()"));
1754        assert!(android_test.contains("activity.checkWorkerExit()"));
1755        assert!(android_test.contains("Benchmark failed before BENCH_JSON"));
1756
1757        let ios_test = include_str!(
1758            "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template"
1759        );
1760        assert!(
1761            ios_test.contains("\\\"error\\\""),
1762            "iOS XCUITest template should fail when the benchmark report is an error payload"
1763        );
1764
1765        let android_manifest =
1766            include_str!("../templates/android/app/src/main/AndroidManifest.xml");
1767        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE"));
1768        assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC"));
1769        assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\""));
1770        assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\""));
1771        assert!(android_manifest.contains("android:process=\":mobench_worker\""));
1772
1773        let android_build_gradle = include_str!("../templates/android/app/build.gradle");
1774        assert!(android_build_gradle.contains("generatedMainBenchSpec"));
1775        assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())"));
1776
1777        let ios =
1778            include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template");
1779        assert!(
1780            !ios.contains("sample.processPeakMemoryKb"),
1781            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1782        );
1783        assert!(
1784            !ios.contains(r"\.processPeakMemoryKb"),
1785            "iOS template should not require generated bindings to expose processPeakMemoryKb"
1786        );
1787        assert!(ios.contains("optionalProcessPeakMemoryKb(sample)"));
1788        assert!(ios.contains("return [\n                \"name\": name,"));
1789        assert!(
1790            !ios.contains("sample.cpuTimeMs"),
1791            "iOS template should tolerate BenchSample without cpuTimeMs"
1792        );
1793        assert!(
1794            !ios.contains("sample.peakMemoryKb"),
1795            "iOS template should tolerate BenchSample without peakMemoryKb"
1796        );
1797        assert!(
1798            !ios.contains("report.phases"),
1799            "iOS template should tolerate BenchReport without phases"
1800        );
1801        assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }"));
1802        assert!(ios.contains("ProcessMemorySampler"));
1803        assert!(ios.contains("currentProcessResidentMemoryKb"));
1804        assert!(ios.contains("task_info("));
1805        assert!(ios.contains("\"memory_process\": \"benchmark_app\""));
1806        assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:"));
1807        assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb"));
1808
1809        let legacy = include_str!(
1810            "../templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template"
1811        );
1812        assert!(legacy.contains("import UIKit"));
1813        assert!(!legacy.contains("import SwiftUI"));
1814        assert!(!legacy.contains("Task.detached"));
1815        assert!(!legacy.contains("Task.sleep"));
1816        assert!(!legacy.contains("MainActor"));
1817        assert!(legacy.contains("DispatchQueue.global(qos: .userInitiated)"));
1818        assert!(legacy.contains("DispatchQueue.main.async"));
1819        assert!(legacy.contains("textColor = .clear"));
1820        assert!(!legacy.contains(".alpha = 0"));
1821        assert!(legacy.contains("benchmarkReport"));
1822        assert!(legacy.contains("benchmarkCompleted"));
1823        assert!(legacy.contains("benchmarkReportJSON"));
1824        assert!(legacy.contains("BENCH_REPORT_JSON_START"));
1825        assert!(legacy.contains("BENCH_REPORT_JSON_END"));
1826    }
1827
1828    #[test]
1829    fn test_ios_deployment_target_and_runner_selection() {
1830        let ios15 = IosDeploymentTarget::parse("15.0").unwrap();
1831        let ios10 = IosDeploymentTarget::parse("10.0").unwrap();
1832
1833        assert_eq!(IosDeploymentTarget::parse("10").unwrap(), ios10);
1834        assert_eq!(
1835            resolve_ios_runner(&ios15, None).unwrap(),
1836            IosRunner::Swiftui
1837        );
1838        assert_eq!(
1839            resolve_ios_runner(&ios10, None).unwrap(),
1840            IosRunner::UikitLegacy
1841        );
1842        assert!(resolve_ios_runner(&ios10, Some(IosRunner::Swiftui)).is_err());
1843        assert_eq!(
1844            resolve_ios_runner(&ios15, Some(IosRunner::UikitLegacy)).unwrap(),
1845            IosRunner::UikitLegacy
1846        );
1847    }
1848
1849    #[test]
1850    fn test_validate_no_unreplaced_placeholders() {
1851        // Should pass with no placeholders
1852        assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok());
1853
1854        // Should pass with Gradle variables (not our placeholders)
1855        assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok());
1856
1857        // Should fail with unreplaced template placeholders
1858        let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt"));
1859        assert!(result.is_err());
1860        let err = result.unwrap_err().to_string();
1861        assert!(err.contains("{{NAME}}"));
1862    }
1863
1864    #[test]
1865    fn test_to_pascal_case() {
1866        assert_eq!(to_pascal_case("my-project"), "MyProject");
1867        assert_eq!(to_pascal_case("my_project"), "MyProject");
1868        assert_eq!(to_pascal_case("myproject"), "Myproject");
1869        assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject");
1870    }
1871
1872    #[test]
1873    fn test_detect_default_function_finds_benchmark() {
1874        let temp_dir = env::temp_dir().join("mobench-sdk-detect-test");
1875        let _ = fs::remove_dir_all(&temp_dir);
1876        fs::create_dir_all(temp_dir.join("src")).unwrap();
1877
1878        // Create a lib.rs with a benchmark function
1879        let lib_content = r#"
1880use mobench_sdk::benchmark;
1881
1882/// Some docs
1883#[benchmark]
1884fn my_benchmark_func() {
1885    // benchmark code
1886}
1887
1888fn helper_func() {}
1889"#;
1890        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1891        fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1892
1893        let result = detect_default_function(&temp_dir, "my_crate");
1894        assert_eq!(result, Some("my_crate::my_benchmark_func".to_string()));
1895
1896        // Cleanup
1897        fs::remove_dir_all(&temp_dir).ok();
1898    }
1899
1900    #[test]
1901    fn test_detect_default_function_no_benchmark() {
1902        let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test");
1903        let _ = fs::remove_dir_all(&temp_dir);
1904        fs::create_dir_all(temp_dir.join("src")).unwrap();
1905
1906        // Create a lib.rs without benchmark functions
1907        let lib_content = r#"
1908fn regular_function() {
1909    // no benchmark here
1910}
1911"#;
1912        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1913
1914        let result = detect_default_function(&temp_dir, "my_crate");
1915        assert!(result.is_none());
1916
1917        // Cleanup
1918        fs::remove_dir_all(&temp_dir).ok();
1919    }
1920
1921    #[test]
1922    fn test_detect_default_function_pub_fn() {
1923        let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test");
1924        let _ = fs::remove_dir_all(&temp_dir);
1925        fs::create_dir_all(temp_dir.join("src")).unwrap();
1926
1927        // Create a lib.rs with a public benchmark function
1928        let lib_content = r#"
1929#[benchmark]
1930pub fn public_bench() {
1931    // benchmark code
1932}
1933"#;
1934        fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap();
1935
1936        let result = detect_default_function(&temp_dir, "test-crate");
1937        assert_eq!(result, Some("test_crate::public_bench".to_string()));
1938
1939        // Cleanup
1940        fs::remove_dir_all(&temp_dir).ok();
1941    }
1942
1943    #[test]
1944    fn test_resolve_default_function_fallback() {
1945        let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test");
1946        let _ = fs::remove_dir_all(&temp_dir);
1947        fs::create_dir_all(&temp_dir).unwrap();
1948
1949        // No lib.rs exists, should fall back to default
1950        let result = resolve_default_function(&temp_dir, "my-crate", None);
1951        assert_eq!(result, "my_crate::example_benchmark");
1952
1953        // Cleanup
1954        fs::remove_dir_all(&temp_dir).ok();
1955    }
1956
1957    #[test]
1958    fn test_sanitize_bundle_id_component() {
1959        // Hyphens should be removed
1960        assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile");
1961        // Underscores should be removed
1962        assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile");
1963        // Mixed separators should all be removed
1964        assert_eq!(
1965            sanitize_bundle_id_component("my-project_name"),
1966            "myprojectname"
1967        );
1968        // Already valid should remain unchanged (but lowercase)
1969        assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile");
1970        // Numbers should be preserved
1971        assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile");
1972        // Uppercase should be lowercased
1973        assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile");
1974        // Complex case
1975        assert_eq!(
1976            sanitize_bundle_id_component("My-Complex_Project-123"),
1977            "mycomplexproject123"
1978        );
1979    }
1980
1981    #[test]
1982    fn test_generate_ios_project_bundle_id_not_duplicated() {
1983        let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test");
1984        // Clean up any previous test run
1985        let _ = fs::remove_dir_all(&temp_dir);
1986        fs::create_dir_all(&temp_dir).unwrap();
1987
1988        // Use a crate name that would previously cause duplication
1989        let crate_name = "bench-mobile";
1990        let bundle_prefix = "dev.world.benchmobile";
1991        let project_pascal = "BenchRunner";
1992
1993        let result = generate_ios_project(
1994            &temp_dir,
1995            crate_name,
1996            project_pascal,
1997            bundle_prefix,
1998            "bench_mobile::test_func",
1999        );
2000        assert!(
2001            result.is_ok(),
2002            "generate_ios_project failed: {:?}",
2003            result.err()
2004        );
2005
2006        // Verify project.yml was created
2007        let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml");
2008        assert!(project_yml_path.exists(), "project.yml should exist");
2009
2010        // Read and verify the bundle ID is correct (not duplicated)
2011        let project_yml = fs::read_to_string(&project_yml_path).unwrap();
2012
2013        // The bundle ID should be "dev.world.benchmobile.BenchRunner"
2014        // NOT "dev.world.benchmobile.benchmobile"
2015        assert!(
2016            project_yml.contains("dev.world.benchmobile.BenchRunner"),
2017            "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}",
2018            project_yml
2019        );
2020        assert!(
2021            !project_yml.contains("dev.world.benchmobile.benchmobile"),
2022            "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}",
2023            project_yml
2024        );
2025        assert!(
2026            project_yml.contains("embed: false"),
2027            "Static xcframework dependency should be link-only, got:\n{}",
2028            project_yml
2029        );
2030
2031        // Cleanup
2032        fs::remove_dir_all(&temp_dir).ok();
2033    }
2034
2035    #[test]
2036    fn test_generate_ios_project_preserves_existing_resources_on_regeneration() {
2037        let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test");
2038        let _ = fs::remove_dir_all(&temp_dir);
2039        fs::create_dir_all(&temp_dir).unwrap();
2040
2041        generate_ios_project(
2042            &temp_dir,
2043            "bench_mobile",
2044            "BenchRunner",
2045            "dev.world.benchmobile",
2046            "bench_mobile::bench_prepare",
2047        )
2048        .unwrap();
2049
2050        let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources");
2051        fs::create_dir_all(resources_dir.join("nested")).unwrap();
2052        fs::write(
2053            resources_dir.join("bench_spec.json"),
2054            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#,
2055        )
2056        .unwrap();
2057        fs::write(
2058            resources_dir.join("bench_meta.json"),
2059            r#"{"build_id":"build-123"}"#,
2060        )
2061        .unwrap();
2062        fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap();
2063
2064        generate_ios_project(
2065            &temp_dir,
2066            "bench_mobile",
2067            "BenchRunner",
2068            "dev.world.benchmobile",
2069            "bench_mobile::bench_prepare",
2070        )
2071        .unwrap();
2072
2073        assert_eq!(
2074            fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(),
2075            r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#
2076        );
2077        assert_eq!(
2078            fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(),
2079            r#"{"build_id":"build-123"}"#
2080        );
2081        assert_eq!(
2082            fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(),
2083            "keep me"
2084        );
2085
2086        fs::remove_dir_all(&temp_dir).ok();
2087    }
2088
2089    #[test]
2090    fn test_ensure_ios_project_refreshes_existing_content_view_template() {
2091        let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test");
2092        let _ = fs::remove_dir_all(&temp_dir);
2093        fs::create_dir_all(&temp_dir).unwrap();
2094
2095        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2096            .expect("initial iOS project generation should succeed");
2097
2098        let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift");
2099        assert!(content_view_path.exists(), "ContentView.swift should exist");
2100
2101        fs::write(&content_view_path, "stale generated content").unwrap();
2102
2103        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2104            .expect("refreshing existing iOS project should succeed");
2105
2106        let refreshed = fs::read_to_string(&content_view_path).unwrap();
2107        assert!(
2108            refreshed.contains("ProfileLaunchOptions"),
2109            "refreshed ContentView.swift should contain the latest profiling template, got:\n{}",
2110            refreshed
2111        );
2112        assert!(
2113            refreshed.contains("repeatUntilMs"),
2114            "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}",
2115            refreshed
2116        );
2117        assert!(
2118            refreshed.contains("Task.detached(priority: .userInitiated)"),
2119            "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}",
2120            refreshed
2121        );
2122        assert!(
2123            refreshed.contains("await MainActor.run"),
2124            "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}",
2125            refreshed
2126        );
2127
2128        fs::remove_dir_all(&temp_dir).ok();
2129    }
2130
2131    #[test]
2132    fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() {
2133        let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test");
2134        let _ = fs::remove_dir_all(&temp_dir);
2135        fs::create_dir_all(&temp_dir).unwrap();
2136
2137        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2138            .expect("initial iOS project generation should succeed");
2139
2140        let ui_test_path =
2141            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2142        assert!(
2143            ui_test_path.exists(),
2144            "BenchRunnerUITests.swift should exist"
2145        );
2146
2147        fs::write(&ui_test_path, "stale generated content").unwrap();
2148
2149        ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None)
2150            .expect("refreshing existing iOS project should succeed");
2151
2152        let refreshed = fs::read_to_string(&ui_test_path).unwrap();
2153        assert!(
2154            refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"),
2155            "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}",
2156            refreshed
2157        );
2158        assert!(
2159            refreshed.contains(
2160                "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]"
2161            ),
2162            "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}",
2163            refreshed
2164        );
2165
2166        fs::remove_dir_all(&temp_dir).ok();
2167    }
2168
2169    #[test]
2170    fn test_generate_ios_project_uses_configured_benchmark_timeout() {
2171        let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test");
2172        let _ = fs::remove_dir_all(&temp_dir);
2173        fs::create_dir_all(&temp_dir).unwrap();
2174
2175        let result = generate_ios_project_with_timeout(
2176            &temp_dir,
2177            "sample_fns",
2178            "BenchRunner",
2179            "dev.world.samplefns",
2180            "sample_fns::example_benchmark",
2181            1200,
2182        );
2183
2184        assert!(result.is_ok(), "generate_ios_project should succeed");
2185
2186        let ui_test_path =
2187            temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
2188        let contents = fs::read_to_string(&ui_test_path).unwrap();
2189        assert!(
2190            contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"),
2191            "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}",
2192            contents
2193        );
2194
2195        fs::remove_dir_all(&temp_dir).ok();
2196    }
2197
2198    #[test]
2199    fn test_generate_ios_project_uses_configured_deployment_target() {
2200        let temp_dir = env::temp_dir().join("mobench-sdk-ios-deployment-target-test");
2201        let _ = fs::remove_dir_all(&temp_dir);
2202        fs::create_dir_all(&temp_dir).unwrap();
2203
2204        generate_ios_project_with_options(
2205            &temp_dir,
2206            "sample_fns",
2207            "BenchRunner",
2208            "dev.world.samplefns",
2209            "sample_fns::example_benchmark",
2210            IosProjectOptions {
2211                deployment_target: IosDeploymentTarget::parse("10.0").unwrap(),
2212                runner: IosRunner::UikitLegacy,
2213                ios_benchmark_timeout_secs: 300,
2214            },
2215        )
2216        .expect("generate legacy iOS project");
2217
2218        let project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")).unwrap();
2219        assert!(project_yml.contains("deploymentTarget: \"10.0\""));
2220        assert!(!project_yml.contains("deploymentTarget: \"15.0\""));
2221
2222        let runner = fs::read_to_string(
2223            temp_dir.join("ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift"),
2224        )
2225        .unwrap();
2226        assert!(runner.contains("import UIKit"));
2227        assert!(
2228            !temp_dir
2229                .join("ios/BenchRunner/BenchRunner/ContentView.swift")
2230                .exists()
2231        );
2232        assert!(
2233            !temp_dir
2234                .join("ios/BenchRunner/BenchRunner/BenchRunnerApp.swift")
2235                .exists()
2236        );
2237
2238        fs::remove_dir_all(&temp_dir).ok();
2239    }
2240
2241    #[test]
2242    fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() {
2243        assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300);
2244        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900);
2245        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300);
2246        assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300);
2247    }
2248
2249    #[test]
2250    fn test_cross_platform_naming_consistency() {
2251        // Test that Android and iOS use the same naming convention for package/bundle IDs
2252        let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test");
2253        let _ = fs::remove_dir_all(&temp_dir);
2254        fs::create_dir_all(&temp_dir).unwrap();
2255
2256        let project_name = "bench-mobile";
2257
2258        // Generate Android project
2259        let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func");
2260        assert!(
2261            result.is_ok(),
2262            "generate_android_project failed: {:?}",
2263            result.err()
2264        );
2265
2266        // Generate iOS project (mimicking how ensure_ios_project does it)
2267        let bundle_id_component = sanitize_bundle_id_component(project_name);
2268        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2269        let result = generate_ios_project(
2270            &temp_dir,
2271            &project_name.replace('-', "_"),
2272            "BenchRunner",
2273            &bundle_prefix,
2274            "bench_mobile::test_func",
2275        );
2276        assert!(
2277            result.is_ok(),
2278            "generate_ios_project failed: {:?}",
2279            result.err()
2280        );
2281
2282        // Read Android build.gradle to extract package name
2283        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2284            .expect("Failed to read Android build.gradle");
2285
2286        // Read iOS project.yml to extract bundle ID prefix
2287        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2288            .expect("Failed to read iOS project.yml");
2289
2290        // Both should use "benchmobile" (without hyphens or underscores)
2291        // Android: namespace = "dev.world.benchmobile"
2292        // iOS: bundleIdPrefix: dev.world.benchmobile
2293        assert!(
2294            android_build_gradle.contains("dev.world.benchmobile"),
2295            "Android package should be 'dev.world.benchmobile', got:\n{}",
2296            android_build_gradle
2297        );
2298        assert!(
2299            ios_project_yml.contains("dev.world.benchmobile"),
2300            "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}",
2301            ios_project_yml
2302        );
2303
2304        // Ensure Android doesn't use hyphens or underscores in the package ID component
2305        assert!(
2306            !android_build_gradle.contains("dev.world.bench-mobile"),
2307            "Android package should NOT contain hyphens"
2308        );
2309        assert!(
2310            !android_build_gradle.contains("dev.world.bench_mobile"),
2311            "Android package should NOT contain underscores"
2312        );
2313
2314        // Cleanup
2315        fs::remove_dir_all(&temp_dir).ok();
2316    }
2317
2318    #[test]
2319    fn test_cross_platform_version_consistency() {
2320        // Test that Android and iOS use the same version strings
2321        let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test");
2322        let _ = fs::remove_dir_all(&temp_dir);
2323        fs::create_dir_all(&temp_dir).unwrap();
2324
2325        let project_name = "test-project";
2326
2327        // Generate Android project
2328        let result = generate_android_project(&temp_dir, project_name, "test_project::test_func");
2329        assert!(
2330            result.is_ok(),
2331            "generate_android_project failed: {:?}",
2332            result.err()
2333        );
2334
2335        // Generate iOS project
2336        let bundle_id_component = sanitize_bundle_id_component(project_name);
2337        let bundle_prefix = format!("dev.world.{}", bundle_id_component);
2338        let result = generate_ios_project(
2339            &temp_dir,
2340            &project_name.replace('-', "_"),
2341            "BenchRunner",
2342            &bundle_prefix,
2343            "test_project::test_func",
2344        );
2345        assert!(
2346            result.is_ok(),
2347            "generate_ios_project failed: {:?}",
2348            result.err()
2349        );
2350
2351        // Read Android build.gradle
2352        let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle"))
2353            .expect("Failed to read Android build.gradle");
2354
2355        // Read iOS project.yml
2356        let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml"))
2357            .expect("Failed to read iOS project.yml");
2358
2359        // Both should use version "1.0.0"
2360        assert!(
2361            android_build_gradle.contains("versionName \"1.0.0\""),
2362            "Android versionName should be '1.0.0', got:\n{}",
2363            android_build_gradle
2364        );
2365        assert!(
2366            ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""),
2367            "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}",
2368            ios_project_yml
2369        );
2370
2371        // Cleanup
2372        fs::remove_dir_all(&temp_dir).ok();
2373    }
2374
2375    #[test]
2376    fn test_bundle_id_prefix_consistency() {
2377        // Test that the bundle ID prefix format is consistent across platforms
2378        let test_cases = vec![
2379            ("my-project", "dev.world.myproject"),
2380            ("bench_mobile", "dev.world.benchmobile"),
2381            ("TestApp", "dev.world.testapp"),
2382            ("app-with-many-dashes", "dev.world.appwithmanydashes"),
2383            (
2384                "app_with_many_underscores",
2385                "dev.world.appwithmanyunderscores",
2386            ),
2387        ];
2388
2389        for (input, expected_prefix) in test_cases {
2390            let sanitized = sanitize_bundle_id_component(input);
2391            let full_prefix = format!("dev.world.{}", sanitized);
2392            assert_eq!(
2393                full_prefix, expected_prefix,
2394                "For input '{}', expected '{}' but got '{}'",
2395                input, expected_prefix, full_prefix
2396            );
2397        }
2398    }
2399}