go_html_template 0.2.1

A pure Rust crate with Go html/template-like API and behavior
Documentation
use go_html_template::{Template, Value};
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Instant, SystemTime, UNIX_EPOCH};

const DEFAULT_TEMPLATE: &str = "{{range $i, $v := .Items}}{{$i}}:{{$v}};{{end}}";
const DEFAULT_ITEMS: usize = 20_000;

#[derive(Debug)]
struct Args {
    template: Option<PathBuf>,
    data: Option<PathBuf>,
    loops: usize,
    missingkey: String,
    go_bin: String,
    go_runner: PathBuf,
}

#[derive(Debug, Deserialize)]
struct GoReport {
    parse_avg_us: u64,
    exec_avg_us: u64,
    output: String,
    output_len: usize,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = parse_args()?;
    let missingkey_option = format!("missingkey={}", args.missingkey);

    let (template_path, template_temp) = prepare_template_path(args.template.as_deref())?;
    let (data_path, data_temp) = prepare_data_path(args.data.as_deref())?;

    let template_source = fs::read_to_string(&template_path)?;
    let data_source = fs::read_to_string(&data_path)?;
    let data: serde_json::Value = serde_json::from_str(&data_source)?;

    let rust_parse_avg_us = bench_rust_parse(&template_source, &missingkey_option, args.loops)?;
    let (rust_exec_avg_us, rust_output) =
        bench_rust_exec(&template_source, &data, &missingkey_option, args.loops)?;

    let go_report = run_go(
        &args.go_bin,
        &args.go_runner,
        &template_path,
        &data_path,
        &args.missingkey,
        args.loops,
    )?;

    let output_match = rust_output == go_report.output;

    println!("template={}", template_path.display());
    println!("data={}", data_path.display());
    println!("loops={}", args.loops);
    println!("missingkey={}", args.missingkey);
    println!();
    println!("{:<24} avg_us={}", "rust_parse", rust_parse_avg_us);
    println!("{:<24} avg_us={}", "rust_execute", rust_exec_avg_us);
    println!("{:<24} avg_us={}", "go_parse", go_report.parse_avg_us);
    println!("{:<24} avg_us={}", "go_execute", go_report.exec_avg_us);
    println!();
    println!("{:<24} {}", "rust_output_len", rust_output.len());
    println!("{:<24} {}", "go_output_len", go_report.output_len);
    println!("{:<24} {}", "output_match", output_match);

    if !output_match {
        println!(
            "{:<24} {}",
            "first_diff_byte",
            first_diff_byte_index(&rust_output, &go_report.output)
                .map(|v| v.to_string())
                .unwrap_or_else(|| "none".to_string())
        );
        println!(
            "{:<24} {:?}",
            "rust_preview",
            preview_for_log(&rust_output, 200)
        );
        println!(
            "{:<24} {:?}",
            "go_preview",
            preview_for_log(&go_report.output, 200)
        );
    }

    drop(template_temp);
    drop(data_temp);

    Ok(())
}

fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
    let mut args = Args {
        template: None,
        data: None,
        loops: 60,
        missingkey: "default".to_string(),
        go_bin: "go".to_string(),
        go_runner: PathBuf::from("tools/compare_go_html_template/main.go"),
    };

    let mut iter = env::args().skip(1);
    while let Some(flag) = iter.next() {
        match flag.as_str() {
            "--template" => {
                args.template = Some(PathBuf::from(next_value(&mut iter, "--template")?))
            }
            "--data" => args.data = Some(PathBuf::from(next_value(&mut iter, "--data")?)),
            "--loops" => {
                let value = next_value(&mut iter, "--loops")?;
                args.loops = value.parse::<usize>()?;
            }
            "--missingkey" => args.missingkey = next_value(&mut iter, "--missingkey")?,
            "--go-bin" => args.go_bin = next_value(&mut iter, "--go-bin")?,
            "--go-runner" => args.go_runner = PathBuf::from(next_value(&mut iter, "--go-runner")?),
            "--help" | "-h" => {
                print_help();
                std::process::exit(0);
            }
            _ => {
                return Err(format!("unknown flag: {flag}").into());
            }
        }
    }

    if args.loops == 0 {
        return Err("--loops must be greater than zero".into());
    }
    if !matches!(
        args.missingkey.as_str(),
        "default" | "invalid" | "zero" | "error"
    ) {
        return Err("--missingkey must be one of default|invalid|zero|error".into());
    }

    Ok(args)
}

fn next_value(
    iter: &mut impl Iterator<Item = String>,
    flag: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    iter.next()
        .ok_or_else(|| format!("{flag} requires a value").into())
}

fn print_help() {
    println!("Compare this crate and Go html/template with the same template/data.");
    println!();
    println!("Usage:");
    println!("  cargo run --release --bin compare_go_html_template -- [options]");
    println!();
    println!("Options:");
    println!("  --template <path>    template file path (default: built-in range template)");
    println!("  --data <path>        JSON data path (default: built-in Items[0..20000))");
    println!("  --loops <n>          benchmark loops for parse/execute (default: 60)");
    println!("  --missingkey <mode>  default|invalid|zero|error (default: default)");
    println!("  --go-bin <path>      go binary path (default: go)");
    println!(
        "  --go-runner <path>   go runner path (default: tools/compare_go_html_template/main.go)"
    );
}

fn prepare_template_path(
    path: Option<&Path>,
) -> Result<(PathBuf, Option<TempFileGuard>), Box<dyn std::error::Error>> {
    if let Some(path) = path {
        return Ok((path.to_path_buf(), None));
    }

    let temp = TempFileGuard::new("template", DEFAULT_TEMPLATE)?;
    Ok((temp.path.clone(), Some(temp)))
}

fn prepare_data_path(
    path: Option<&Path>,
) -> Result<(PathBuf, Option<TempFileGuard>), Box<dyn std::error::Error>> {
    if let Some(path) = path {
        return Ok((path.to_path_buf(), None));
    }

    let data = json!({"Items": (0..DEFAULT_ITEMS).collect::<Vec<_>>()});
    let json_text = serde_json::to_string(&data)?;
    let temp = TempFileGuard::new("data", &json_text)?;
    Ok((temp.path.clone(), Some(temp)))
}

fn bench_rust_parse(
    template: &str,
    missingkey_option: &str,
    loops: usize,
) -> Result<u128, Box<dyn std::error::Error>> {
    let start = Instant::now();
    for _ in 0..loops {
        let _ = Template::new("bench")
            .option(missingkey_option)?
            .parse(template)?;
    }
    Ok(start.elapsed().as_micros() / loops as u128)
}

fn bench_rust_exec(
    template: &str,
    data: &serde_json::Value,
    missingkey_option: &str,
    loops: usize,
) -> Result<(u128, String), Box<dyn std::error::Error>> {
    let parsed = Template::new("bench")
        .option(missingkey_option)?
        .parse(template)?;
    let root = Value::from_serializable(data)?;

    let start = Instant::now();
    let mut output = String::new();
    for _ in 0..loops {
        output = parsed.execute_value_to_string(&root)?;
    }
    let avg = start.elapsed().as_micros() / loops as u128;
    Ok((avg, output))
}

fn run_go(
    go_bin: &str,
    go_runner: &Path,
    template_path: &Path,
    data_path: &Path,
    missingkey: &str,
    loops: usize,
) -> Result<GoReport, Box<dyn std::error::Error>> {
    if !go_runner.exists() {
        return Err(format!("go runner not found: {}", go_runner.display()).into());
    }

    let output = Command::new(go_bin)
        .arg("run")
        .arg(go_runner)
        .arg("--template")
        .arg(template_path)
        .arg("--data")
        .arg(data_path)
        .arg("--loops")
        .arg(loops.to_string())
        .arg("--missingkey")
        .arg(missingkey)
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        return Err(format!(
            "go runner failed (status={}):\nstdout:\n{}\nstderr:\n{}",
            output.status, stdout, stderr
        )
        .into());
    }

    let report: GoReport = serde_json::from_slice(&output.stdout)?;
    Ok(report)
}

fn first_diff_byte_index(left: &str, right: &str) -> Option<usize> {
    let left_bytes = left.as_bytes();
    let right_bytes = right.as_bytes();
    let min_len = left_bytes.len().min(right_bytes.len());

    for index in 0..min_len {
        if left_bytes[index] != right_bytes[index] {
            return Some(index);
        }
    }

    if left_bytes.len() == right_bytes.len() {
        None
    } else {
        Some(min_len)
    }
}

fn preview_for_log(text: &str, max_chars: usize) -> String {
    text.chars().take(max_chars).collect()
}

struct TempFileGuard {
    path: PathBuf,
}

impl TempFileGuard {
    fn new(tag: &str, content: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let mut path = env::temp_dir();
        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
        path.push(format!(
            "go_html_template_compare_{}_{}_{}.tmp",
            tag,
            std::process::id(),
            timestamp
        ));
        fs::write(&path, content)?;
        Ok(Self { path })
    }
}

impl Drop for TempFileGuard {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.path);
    }
}