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