use std::fs;
use std::path::{Path, PathBuf};
use clap::Args;
use tracing::{info, warn};
use rs_poker::arena::agent::{AgentConfig, ConfigAgentBuilder};
use rs_poker::arena::cfr::{PositionCharts, PreflopChartConfig};
use rs_poker::holdem::{PreflopChart, PreflopHand, PreflopScenario};
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("failed to read path '{path}': {source}")]
ReadPath {
path: String,
source: std::io::Error,
},
#[error("{failed} of {total} config(s) failed verification")]
VerificationFailed { failed: usize, total: usize },
}
#[derive(Args, Debug, Clone)]
pub struct VerifyArgs {
path: PathBuf,
#[arg(long, default_value_t = false)]
summary: bool,
}
pub fn run(args: VerifyArgs) -> Result<(), VerifyError> {
let paths = collect_config_paths(&args.path)?;
if paths.is_empty() {
info!("No .json files found at '{}'", args.path.display());
return Ok(());
}
let mut ok = 0usize;
let mut failed = 0usize;
for path in &paths {
match ConfigAgentBuilder::from_file(path) {
Ok(builder) => {
info!("OK {}", path.display());
ok += 1;
if args.summary
&& let AgentConfig::CfrPreflopChart { preflop_config, .. } = builder.config()
{
match preflop_config.resolve() {
Ok(cfg) => print_chart_summary(&cfg),
Err(e) => warn!(" could not resolve preflop config: {}", e),
}
}
}
Err(e) => {
warn!("FAIL {}: {}", path.display(), e);
failed += 1;
}
}
}
let total = paths.len();
info!("{} OK, {} failed ({} total)", ok, failed, total);
if failed > 0 {
return Err(VerifyError::VerificationFailed { failed, total });
}
Ok(())
}
fn collect_config_paths(path: &Path) -> Result<Vec<PathBuf>, VerifyError> {
let meta = fs::metadata(path).map_err(|e| VerifyError::ReadPath {
path: path.display().to_string(),
source: e,
})?;
if meta.is_file() {
return Ok(vec![path.to_path_buf()]);
}
let entries = fs::read_dir(path).map_err(|e| VerifyError::ReadPath {
path: path.display().to_string(),
source: e,
})?;
let mut paths: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();
paths.sort();
Ok(paths)
}
fn print_chart_summary(cfg: &PreflopChartConfig) {
info!(
" raise {:.2}bb · 3bet×{:.1} · 4bet+×{:.1} · positions {}",
cfg.raise_size_bb,
cfg.three_bet_multiplier,
cfg.four_bet_plus_multiplier,
cfg.positions.len()
);
for (idx, position) in cfg.positions.iter().enumerate() {
let label = position_label(idx, cfg.positions.len());
info!(" position {} ({}):", idx, label);
for scenario in PreflopScenario::all() {
print_scenario_summary(position, scenario);
}
if let Some(warning) = sanity_warning(idx, position) {
warn!(" ⚠ {}", warning);
}
}
}
fn print_scenario_summary(position: &PositionCharts, scenario: PreflopScenario) {
let chart = position.chart_for(scenario);
let stats = range_stats(chart);
info!(
" {:<8} raise {:>5.1}% call {:>5.1}% fold {:>5.1}% ({} hands)",
scenario.label(),
stats.raise_pct * 100.0,
stats.call_pct * 100.0,
stats.fold_pct * 100.0,
chart.len()
);
}
struct RangeStats {
raise_pct: f32,
call_pct: f32,
fold_pct: f32,
}
fn range_stats(chart: &PreflopChart) -> RangeStats {
let mut raise = 0.0f32;
let mut call = 0.0f32;
let mut fold = 0.0f32;
let mut total = 0.0f32;
for hand in PreflopHand::all() {
let weight = combo_weight(&hand);
total += weight;
match chart.get(&hand) {
Some(strategy) => {
raise += strategy.raise() * weight;
call += strategy.call() * weight;
fold += strategy.fold_freq() * weight;
}
None => fold += weight,
}
}
if total <= 0.0 {
return RangeStats {
raise_pct: 0.0,
call_pct: 0.0,
fold_pct: 0.0,
};
}
RangeStats {
raise_pct: raise / total,
call_pct: call / total,
fold_pct: fold / total,
}
}
fn combo_weight(hand: &PreflopHand) -> f32 {
if hand.is_pair() {
6.0
} else if hand.suited() {
4.0
} else {
12.0
}
}
fn position_label(idx: usize, num_positions: usize) -> String {
match (idx, num_positions) {
(0, _) => "BB".to_string(),
(1, 2) => "BTN".to_string(),
(1, _) => "SB".to_string(),
(2, _) => "BTN".to_string(),
(3, _) => "CO".to_string(),
(4, _) => "HJ".to_string(),
(5, _) => "UTG".to_string(),
(n, _) => format!("UTG+{}", n - 5),
}
}
fn sanity_warning(position: usize, charts: &PositionCharts) -> Option<String> {
if (position == 2 || position == 3) && charts.rfi.is_empty() {
return Some(format!(
"position {} has no RFI chart — likely incomplete",
position
));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_json(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
let mut f = fs::File::create(&path).unwrap();
f.write_all(body.as_bytes()).unwrap();
path
}
#[test]
fn valid_config_dir_passes() {
let tmp = TempDir::new().unwrap();
write_json(tmp.path(), "a.json", r#"{"type":"all_in"}"#);
write_json(tmp.path(), "b.json", r#"{"type":"calling"}"#);
run(VerifyArgs {
path: tmp.path().to_path_buf(),
summary: false,
})
.unwrap();
}
#[test]
fn invalid_config_dir_fails() {
let tmp = TempDir::new().unwrap();
write_json(tmp.path(), "ok.json", r#"{"type":"all_in"}"#);
write_json(tmp.path(), "bad.json", r#"{"name":"nope"}"#);
let err = run(VerifyArgs {
path: tmp.path().to_path_buf(),
summary: false,
})
.unwrap_err();
assert!(matches!(
err,
VerifyError::VerificationFailed {
failed: 1,
total: 2
}
));
}
#[test]
fn single_file_verifies() {
let tmp = TempDir::new().unwrap();
let file = write_json(tmp.path(), "single.json", r#"{"type":"folding"}"#);
run(VerifyArgs {
path: file,
summary: false,
})
.unwrap();
}
#[test]
fn missing_path_errors() {
let err = run(VerifyArgs {
path: PathBuf::from("/does/not/exist/configs"),
summary: false,
})
.unwrap_err();
assert!(matches!(err, VerifyError::ReadPath { .. }));
}
#[test]
fn non_json_files_ignored() {
let tmp = TempDir::new().unwrap();
write_json(tmp.path(), "a.json", r#"{"type":"all_in"}"#);
write_json(tmp.path(), "notes.txt", "not json at all");
run(VerifyArgs {
path: tmp.path().to_path_buf(),
summary: false,
})
.unwrap();
}
#[test]
fn empty_dir_passes() {
let tmp = TempDir::new().unwrap();
run(VerifyArgs {
path: tmp.path().to_path_buf(),
summary: false,
})
.unwrap();
}
#[test]
fn subdirectory_not_recursed() {
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("sub");
fs::create_dir(&sub).unwrap();
write_json(&sub, "bad.json", r#"{"name":"no type field"}"#);
run(VerifyArgs {
path: tmp.path().to_path_buf(),
summary: false,
})
.unwrap();
}
#[test]
fn range_stats_empty_chart_is_all_fold() {
let chart = PreflopChart::new();
let stats = range_stats(&chart);
assert!((stats.fold_pct - 1.0).abs() < 1e-5);
assert!(stats.raise_pct.abs() < 1e-5);
assert!(stats.call_pct.abs() < 1e-5);
}
#[test]
fn range_stats_pure_raise_hand_contributes_combo_weight() {
use rs_poker::core::Value;
use rs_poker::holdem::PreflopStrategy;
let mut chart = PreflopChart::new();
let aa = PreflopHand::new(Value::Ace, Value::Ace, false);
chart.set(aa, PreflopStrategy::pure_raise());
let stats = range_stats(&chart);
let expected = 6.0 / 1326.0;
assert!((stats.raise_pct - expected).abs() < 1e-5);
}
#[test]
fn sanity_warning_silent_for_bb_rfi() {
use rs_poker::core::Value;
use rs_poker::holdem::PreflopStrategy;
let mut charts = PositionCharts::default();
charts.rfi.set(
PreflopHand::new(Value::Ace, Value::Ace, false),
PreflopStrategy::pure_raise(),
);
assert!(sanity_warning(0, &charts).is_none());
}
#[test]
fn sanity_warning_flags_btn_missing_rfi() {
let charts = PositionCharts::default();
assert!(sanity_warning(2, &charts).is_some());
}
#[test]
fn sanity_warning_silent_for_utg_empty_rfi() {
let charts = PositionCharts::default();
assert!(sanity_warning(5, &charts).is_none());
}
}