belt 3.6.0

A fast, cross-platform Factorio benchmarking tool
Documentation
use std::{
    collections::HashMap,
    path::{Path, PathBuf},
};

use chrono::Local;
use handlebars::Handlebars;
use serde_json::json;

use crate::{
    benchmark::parser::{BenchmarkRun, MimallocStats},
    core::{
        error::{BenchmarkErrorKind, Result},
        output::{ResultWriter, WriteData, ensure_output_dir},
    },
};

pub struct ReportWriter {}

impl Default for ReportWriter {
    fn default() -> Self {
        Self::new()
    }
}

impl ReportWriter {
    pub fn new() -> Self {
        Self {}
    }
}

impl ResultWriter for ReportWriter {
    fn write(&self, data: &WriteData, path: &Path) -> Result<()> {
        match data {
            WriteData::Report {
                data,
                template_path,
            } => write_report(data, template_path.as_deref(), path),
            _ => Err(BenchmarkErrorKind::InvalidWriteData.into()), // TODO
        }
    }
}

/// Write the results to a Handlebars file
fn write_report(results: &[BenchmarkRun], template_path: Option<&Path>, path: &Path) -> Result<()> {
    const TPL_STR: &str = "# Factorio Benchmark Results\n\n**Platform:** {{platform}}\n**Factorio Version:** {{factorio_version}}\n**Date:** {{date}}\n\n## Scenario\n* Each save was tested for {{ticks}} tick(s) and {{runs}} run(s)\n\n## Results\n| Metric            | Description                           |\n| ----------------- | ------------------------------------- |\n| **Mean UPS**      | Updates per second – higher is better |\n| **Mean Avg (ms)** | Average frame time – lower is better  |\n| **Mean Min (ms)** | Minimum frame time – lower is better  |\n| **Mean Max (ms)** | Maximum frame time – lower is better  |\n\n| Save | Avg (ms) | Min (ms) | Max (ms) | UPS | Execution Time (ms) | % Difference from base |\n|------|----------|----------|----------|-----|---------------------|------------------------|\n{{#each results}}\n| {{save_name}} | {{avg_ms}} | {{min_ms}} | {{max_ms}} | {{{avg_effective_ups}}} | {{total_execution_time_ms}} | {{percentage_improvement}} |\n{{/each}}\n\n{{#if results.0.mimalloc}}\n## Memory (mimalloc)\n\n### What these numbers mean (practical interpretation)\n| Field | What it roughly indicates |\n|------|----------------------------|\n| **Committed (peak)** | Highest amount of memory backed by the OS during the run (best \"memory footprint\" trend metric). |\n| **Reserved (peak)** | Highest virtual address space reserved by the allocator. **If Committed > Reserved, the application uses direct `mmap`/`VirtualAlloc` outside the allocator** (e.g., for memory-mapped files or custom pools). |\n| **Peak RSS** | Highest resident set size (what was actually in RAM). Large gaps between Committed and RSS indicate sparse memory usage (hugepages, memory-mapped files, or reserved-but-untouched arenas). |\n| **Commit Efficiency** | `(Peak RSS / Committed Peak)` as percentage. <10% = sparse allocation (mostly reserved, not touched); >80% = dense working set. |\n| **Committed/Reserved (current)** | What the allocator still held at process exit. Not automatically a leak—mimalloc retains arenas for reuse. **Trend this across multiple runs; growth between identical runs indicates leaks.** |\n| **Pages / Abandoned (current + status)** | \"Not all freed\" is **normal**—the allocator caches pages for reuse. Abandoned blocks indicate thread-local heap fragments from terminated threads. Flag only if these numbers grow across benchmark iterations. |\n| **Thread Churn** | `(Threads Peak - Current)`. Values >0 indicate short-lived worker threads spawned during initialization (explains Abandoned blocks). |\n| **Threads (peak)** | Peak allocator thread count observed. If Peak > Current, expect elevated Abandoned blocks. |\n| **mmaps** | Number of OS allocation calls. Low counts (<50) with high memory usage indicate efficient arena reuse. High counts indicate frequent allocation pressure or fragmentation. |\n| **purges / resets** | Memory returned to OS. Usually 0 in benchmarks—non-zero indicates aggressive memory trimming or constrained environments. |\n\n### Summary (end-of-run heap stats)\n| Save | Committed Peak | Peak RSS | Commit Efficiency | Reserved Peak | Committed Current | Reserved Current | Pages Current | Pages Status | Abandoned Current | Abandoned Status | Thread Churn | Threads Peak | mmaps | purges | resets |\n|------|----------------|----------|-------------------|---------------|-------------------|------------------|---------------|-------------|-------------------|------------------|--------------|-------------|-------|--------|--------|\n{{#each results}}\n{{#each mimalloc}}\n| {{../save_name}} | {{committed_peak}} | {{peak_rss}} | {{commit_efficiency}} | {{reserved_peak}} | {{committed_current}} | {{reserved_current}} | {{pages_current}} | {{pages_status}} | {{abandoned_current}} | {{abandoned_status}} | {{thread_churn}} | {{threads_peak}} | {{mmaps}} | {{purges}} | {{resets}} |\n{{/each}}\n{{/each}}\n\n{{/if}}\n## Conclusion";
    ensure_output_dir(path)?;

    let mut handlebars = Handlebars::new();
    // Check for legacy path, otherwise use template string
    let results_path = if let Some(template_path) = template_path {
        let file_name = if template_path.extension().and_then(|s| s.to_str()) == Some("hbs") {
            template_path.file_stem().map(PathBuf::from).unwrap()
        } else {
            PathBuf::from("results.md")
        };

        handlebars.register_template_file("benchmark", template_path)?;

        path.join(file_name)
    } else {
        let legacy_path = PathBuf::from("templates/results.md.hbs");
        if legacy_path.exists() {
            handlebars.register_template_file("benchmark", legacy_path)?;
        } else {
            handlebars.register_template_string("benchmark", TPL_STR)?;
        }
        path.join("results.md")
    };

    // Calculate aggregated metrics for each benchmark result
    let aggs = aggregate_by_save_name(results);

    let mut table_results = Vec::new();
    for a in &aggs {
        let n = a.runs.max(1) as f64;

        let avg_ms = a.avg_ms / n;
        let avg_effective_ups = a.effective_ups / n;
        let avg_base_diff = a.base_diff / n;

        let min_ms = if a.min_ms.is_infinite() {
            0.0
        } else {
            a.min_ms
        };
        let max_ms = if a.max_ms.is_infinite() {
            0.0
        } else {
            a.max_ms
        };

        table_results.push(json!({
            "save_name": a.save_name,
            "avg_ms": format!("{:.3}", avg_ms),
            "min_ms": format!("{:.3}", min_ms),
            "max_ms": format!("{:.3}", max_ms),
            "avg_effective_ups": (avg_effective_ups as u64).to_string(),
            "percentage_improvement": format!("{:.2}%", avg_base_diff),
            "total_execution_time_ms": a.total_execution_time_ms as u64,
            "mimalloc": a.mimalloc_stats,
        }));
    }

    let bolding_tags = match results_path.extension().and_then(|s| s.to_str()) {
        Some("html") => ("<strong>", "</strong>"),
        Some("md") => ("**", "**"),
        _ => ("**", "**"),
    };

    // Find the highest avg_effective_ups across all benchmarks for highlighting
    if !table_results.is_empty() {
        let max_avg_ups = table_results
            .iter()
            .map(|r| {
                r["avg_effective_ups"]
                    .as_str()
                    .unwrap_or("0")
                    .parse::<u64>()
                    .unwrap_or(0)
            })
            .max()
            .unwrap_or(0);

        // Add bold formatting to the highest UPS value
        for result in &mut table_results {
            let ups_str = result["avg_effective_ups"].as_str().unwrap_or("0");
            let ups = ups_str.parse::<u64>().unwrap_or(0);
            if ups == max_avg_ups {
                result["avg_effective_ups"] =
                    json!(format!("{}{}{}", bolding_tags.0, ups, bolding_tags.1));
            }
        }
    }

    let data = json!({
        "platform": results.first().map(|run| run.platform.as_str()),
        "factorio_version": results.first().map(|run| run.factorio_version.as_str()),
        "results": table_results,
        "ticks": results.first().map(|run| run.ticks).unwrap_or(0),
        "runs": results.len(),
        "date": Local::now().date_naive().to_string(),
    });

    let rendered = handlebars.render("benchmark", &data)?;

    std::fs::write(&results_path, rendered)?;

    tracing::info!("Report written to {}", results_path.display());
    Ok(())
}

#[derive(Debug, Clone)]
struct Aggregate {
    save_name: String,

    runs: u32,
    total_execution_time_ms: f64,
    avg_ms: f64,
    min_ms: f64,
    max_ms: f64,
    effective_ups: f64,
    base_diff: f64,

    mimalloc_stats: Vec<MimallocStats>,
}

impl Aggregate {
    fn new(r: &BenchmarkRun) -> Self {
        Self {
            save_name: r.save_name.clone(),

            runs: 0,
            total_execution_time_ms: 0.0,
            avg_ms: 0.0,
            min_ms: f64::INFINITY,
            max_ms: f64::NEG_INFINITY,
            effective_ups: 0.0,
            base_diff: 0.0,

            mimalloc_stats: Vec::new(),
        }
    }

    fn push(&mut self, r: &BenchmarkRun) {
        self.runs += 1;
        self.total_execution_time_ms += r.execution_time_ms;

        self.avg_ms += r.avg_ms;
        self.min_ms = self.min_ms.min(r.min_ms);
        self.max_ms = self.max_ms.max(r.max_ms);

        self.effective_ups += r.effective_ups;
        self.base_diff += r.base_diff;

        if let Some(stats) = r.mimalloc_stats.clone() {
            self.mimalloc_stats.push(stats);
        }
    }
}

fn aggregate_by_save_name(runs: &[BenchmarkRun]) -> Vec<Aggregate> {
    let mut map: HashMap<&str, Aggregate> = HashMap::new();

    for run in runs {
        map.entry(run.save_name.as_str())
            .or_insert_with(|| Aggregate::new(run))
            .push(run);
    }

    let mut aggs: Vec<Aggregate> = map.into_values().collect();
    aggs.sort_by(|a, b| a.save_name.cmp(&b.save_name));
    aggs
}