mod analyze;
mod benchmark;
mod blueprint;
mod core;
mod sanitize;
use crate::core::{
GlobalConfig, Result, RunOrder,
config::{self, AnalyzeConfig, BenchmarkConfig, BlueprintConfig, SanitizeConfig},
};
use clap::{Parser, Subcommand};
use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Parser)]
#[command(name = "belt")]
#[command(about = "Factorio benchmarking tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true, help = "Path to Factorio executable")]
factorio_path: Option<PathBuf>,
#[arg(long, global = true, help = "Enable verbose logging")]
verbose: bool,
#[arg(
long,
global = true,
help = "Path to config file (default: ~/.config/belt/config.toml)"
)]
config: Option<PathBuf>,
#[arg(
long,
global = true,
help = "Initialize config directory with example config"
)]
init_config: bool,
}
#[derive(Subcommand)]
enum Commands {
Analyze {
data_dir: PathBuf,
#[arg(
long,
help = "Apply a simple moving average to per-tick data with the given window size. Set to 0 for no smoothing."
)]
smooth_window: Option<u32>,
#[arg(
long,
value_delimiter = ',',
help = "Generate per-tick charts for specified Factorio benchmark metrics (e.g., 'wholeUpdate,gameUpdate'). 'all' to chart all metrics."
)]
verbose_metrics: Option<Vec<String>>,
#[arg(long, help = "Chart height in pixels")]
height: Option<u32>,
#[arg(long, help = "Chart width in pixels")]
width: Option<u32>,
#[arg(
long,
help = "Max data points that the verbose charts can reach before being downsampled."
)]
max_points: Option<usize>,
},
Benchmark {
saves_dir: PathBuf,
#[arg(long, help = "Number of ticks to run each benchmark")]
ticks: Option<u32>,
#[arg(long, help = "Number of benchmark runs per save file")]
runs: Option<u32>,
#[arg(long, help = "Pattern to filter save files")]
pattern: Option<String>,
#[arg(long, help = "Output directory or file path")]
output: Option<PathBuf>,
#[arg(long, help = "Path to HTML report template")]
template_path: Option<PathBuf>,
#[arg(long, help = "Directory containing mods to use")]
mods_dir: Option<PathBuf>,
#[arg(
long,
help = "Execution order: sequential (A,B,A,B), random (A,B,B,A), or grouped (A,A,B,B)"
)]
run_order: Option<RunOrder>,
#[arg(
long,
value_delimiter = ',',
help = "Generate per-tick charts for specified Factorio benchmark metrics (e.g., 'wholeUpdate,gameUpdate'). 'all' to chart all metrics."
)]
verbose_metrics: Option<Vec<String>>,
#[arg(long, help = "Prefix to strip from save file names in output")]
strip_prefix: Option<String>,
#[arg(long, help = "Run Factorio in headless mode")]
headless: Option<bool>,
},
Blueprint {
blueprints_dir: PathBuf,
base_save_path: PathBuf,
#[arg(long, help = "Number of blueprints to test")]
count: Option<u32>,
#[arg(long, help = "Number of buffer ticks before measuring")]
buffer_ticks: Option<u32>,
#[arg(long, help = "Directory containing mods to use")]
mods_dir: Option<PathBuf>,
#[arg(long, help = "Prefix for output file names")]
prefix: Option<String>,
#[arg(long, help = "Pattern to filter blueprint files")]
pattern: Option<String>,
#[arg(long, help = "Output directory or file path")]
output: Option<PathBuf>,
#[arg(long, help = "Run Factorio in headless mode")]
headless: Option<bool>,
#[arg(long, help = "Number of construction bots to use")]
bot_count: Option<u32>,
},
Sanitize {
saves_dir: PathBuf,
#[arg(long, help = "Pattern to filter save files")]
pattern: Option<String>,
#[arg(long, help = "Number of ticks to run sanitization")]
ticks: Option<u32>,
#[arg(long, help = "Directory containing mods to use")]
mods_dir: Option<PathBuf>,
#[arg(long, help = "Output directory for sanitized saves")]
data_dir: Option<PathBuf>,
#[arg(long, help = "Items to preserve during sanitization (comma-separated)")]
items: Option<String>,
#[arg(
long,
help = "Fluids to preserve during sanitization (comma-separated)"
)]
fluids: Option<String>,
#[arg(long, help = "Run Factorio in headless mode")]
headless: Option<bool>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.contains(&"--init-config".to_string()) {
match config::init_config_dir() {
Ok(path) => {
println!("Initialized config directory at: {}", path.display());
return Ok(());
}
Err(e) => {
eprintln!("Failed to initialize config directory: {}", e);
std::process::exit(1);
}
}
}
let cli = Cli::parse();
let figment = if let Some(config_path) = &cli.config {
match config::create_figment_from_file(config_path) {
Ok(f) => f,
Err(e) => {
eprintln!("Failed to load config file: {}", e);
std::process::exit(1);
}
}
} else {
config::create_figment().unwrap_or_else(|_| {
figment::Figment::new()
})
};
if cli.verbose {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
} else {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
}
let mut global_config = GlobalConfig::from_figment(&figment).unwrap_or_default();
if cli.factorio_path.is_some() {
global_config.factorio_path = cli.factorio_path;
}
if cli.verbose {
global_config.verbose = cli.verbose;
}
let needs_shutdown = matches!(
cli.command,
Commands::Benchmark { .. } | Commands::Sanitize { .. } | Commands::Blueprint { .. }
);
let running = Arc::new(AtomicBool::new(true));
let shutdown_task = if needs_shutdown {
let r = running.clone();
Some(tokio::spawn(async move {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!("Failed to listen for CTRL+C: {e}");
}
tracing::info!("Received CTRL+C. Initiating graceful shutdown...");
r.store(false, Ordering::SeqCst);
}))
} else {
None
};
let result = match cli.command {
Commands::Analyze {
data_dir,
smooth_window,
verbose_metrics,
height,
width,
max_points,
} => {
let mut analyze_config = AnalyzeConfig::from_figment(&figment).unwrap_or_default();
analyze_config.data_dir = data_dir;
if let Some(v) = smooth_window {
analyze_config.smooth_window = v;
}
if let Some(v) = verbose_metrics {
analyze_config.verbose_metrics = v;
}
if let Some(v) = height {
analyze_config.height = v;
}
if let Some(v) = width {
analyze_config.width = v;
}
if let Some(v) = max_points {
analyze_config.max_points = Some(v);
}
analyze::run(global_config, analyze_config).await
}
Commands::Benchmark {
saves_dir,
ticks,
runs,
pattern,
output,
template_path,
mods_dir,
run_order,
verbose_metrics,
strip_prefix,
headless,
} => {
let mut benchmark_config = BenchmarkConfig::from_figment(&figment).unwrap_or_default();
benchmark_config.saves_dir = saves_dir;
if let Some(v) = ticks {
benchmark_config.ticks = v;
}
if let Some(v) = runs {
benchmark_config.runs = v;
}
if let Some(v) = pattern {
benchmark_config.pattern = Some(v);
}
if let Some(v) = output {
benchmark_config.output = Some(v);
}
if let Some(v) = template_path {
benchmark_config.template_path = Some(v);
}
if let Some(v) = mods_dir {
benchmark_config.mods_dir = Some(v);
}
if let Some(v) = run_order {
benchmark_config.run_order = v;
}
if let Some(v) = verbose_metrics {
benchmark_config.verbose_metrics = v;
}
if let Some(v) = strip_prefix {
benchmark_config.strip_prefix = Some(v);
}
if let Some(v) = headless {
benchmark_config.headless = Some(v);
}
benchmark::run(global_config, benchmark_config, &running).await
}
Commands::Blueprint {
blueprints_dir,
base_save_path,
count,
buffer_ticks,
mods_dir,
pattern,
output,
prefix,
headless,
bot_count,
} => {
let mut blueprint_config = BlueprintConfig::from_figment(&figment).unwrap_or_default();
blueprint_config.blueprints_dir = blueprints_dir;
blueprint_config.base_save_path = base_save_path;
if let Some(v) = count {
blueprint_config.count = v;
}
if let Some(v) = buffer_ticks {
blueprint_config.buffer_ticks = v;
}
if let Some(v) = mods_dir {
blueprint_config.mods_dir = Some(v);
}
if let Some(v) = pattern {
blueprint_config.pattern = Some(v);
}
if let Some(v) = output {
blueprint_config.output = Some(v);
}
if let Some(v) = prefix {
blueprint_config.prefix = Some(v);
}
if let Some(v) = headless {
blueprint_config.headless = Some(v);
}
if let Some(v) = bot_count {
blueprint_config.bot_count = Some(v);
}
blueprint::run(global_config, blueprint_config, &running).await
}
Commands::Sanitize {
saves_dir,
pattern,
ticks,
mods_dir,
data_dir,
items,
fluids,
headless,
} => {
let mut sanitize_config = SanitizeConfig::from_figment(&figment).unwrap_or_default();
sanitize_config.saves_dir = saves_dir;
if let Some(v) = pattern {
sanitize_config.pattern = Some(v);
}
if let Some(v) = ticks {
sanitize_config.ticks = v;
}
if let Some(v) = mods_dir {
sanitize_config.mods_dir = Some(v);
}
if let Some(v) = data_dir {
sanitize_config.data_dir = Some(v);
}
if let Some(v) = items {
sanitize_config.items = Some(v);
}
if let Some(v) = fluids {
sanitize_config.fluids = Some(v);
}
if let Some(v) = headless {
sanitize_config.headless = Some(v);
}
sanitize::run(global_config, sanitize_config, &running).await
}
};
if let Some(task) = shutdown_task {
let interrupted = !running.load(Ordering::SeqCst);
if interrupted {
let _ = task.await;
tracing::info!("Shutdown complete");
} else {
drop(task);
}
}
if let Err(e) = result {
tracing::error!("{e}");
std::process::exit(1);
}
Ok(())
}