percli 0.2.5

Offline CLI simulator for the Percolator risk engine
use anyhow::{Context, Result};

use crate::format::{self, OutputFormat};
use percli_core::scenario::{runner, Scenario};

pub fn run(
    path: &str,
    fmt: OutputFormat,
    step_by_step: bool,
    check_conservation: bool,
    verbose: bool,
    overrides: &[String],
) -> Result<()> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read scenario file: {path}"))?;

    let mut scenario: Scenario =
        toml::from_str(&content).with_context(|| format!("failed to parse scenario: {path}"))?;

    apply_overrides(&mut scenario, overrides)?;

    if !scenario.meta.name.is_empty() {
        eprintln!("Running: {}", scenario.meta.name);
        if !scenario.meta.description.is_empty() {
            eprintln!("  {}", scenario.meta.description);
        }
        eprintln!();
    }

    let opts = runner::RunOptions {
        check_conservation,
        step_by_step,
        verbose,
    };

    let result = runner::run_scenario(&scenario, &opts)?;

    format::print_run_result(&result, fmt)?;

    let failures: Vec<_> = result
        .step_results
        .iter()
        .filter(|sr| matches!(sr.outcome, runner::StepOutcome::AssertFailed(_)))
        .collect();

    if !failures.is_empty() {
        eprintln!();
        for f in &failures {
            if let runner::StepOutcome::AssertFailed(msg) = &f.outcome {
                eprintln!("ASSERTION FAILED at step {}: {msg}", f.step_num);
            }
        }
        std::process::exit(1);
    }

    Ok(())
}

fn apply_overrides(scenario: &mut Scenario, overrides: &[String]) -> Result<()> {
    for ov in overrides {
        let (key, val) = ov
            .split_once('=')
            .ok_or_else(|| anyhow::anyhow!("override must be key=value, got: {ov}"))?;

        let p = &mut scenario.params;
        match key {
            "warmup_period_slots" => p.warmup_period_slots = val.parse()?,
            "maintenance_margin_bps" => p.maintenance_margin_bps = val.parse()?,
            "initial_margin_bps" => p.initial_margin_bps = val.parse()?,
            "trading_fee_bps" => p.trading_fee_bps = val.parse()?,
            "max_accounts" => p.max_accounts = val.parse()?,
            "new_account_fee" => p.new_account_fee = val.parse()?,
            "maintenance_fee_per_slot" => p.maintenance_fee_per_slot = val.parse()?,
            "max_crank_staleness_slots" => p.max_crank_staleness_slots = val.parse()?,
            "liquidation_fee_bps" => p.liquidation_fee_bps = val.parse()?,
            "liquidation_fee_cap" => p.liquidation_fee_cap = val.parse()?,
            "liquidation_buffer_bps" => p.liquidation_buffer_bps = val.parse()?,
            "min_liquidation_abs" => p.min_liquidation_abs = val.parse()?,
            "min_initial_deposit" => p.min_initial_deposit = val.parse()?,
            "min_nonzero_mm_req" => p.min_nonzero_mm_req = val.parse()?,
            "min_nonzero_im_req" => p.min_nonzero_im_req = val.parse()?,
            "insurance_floor" => p.insurance_floor = val.parse()?,
            _ => anyhow::bail!("unknown parameter: {key}"),
        }
    }
    Ok(())
}