percli 1.0.0

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

use crate::format::{self, status, 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 = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => {
            status::error_with_help(
                &format!("could not read scenario file: {path}"),
                Some(path),
                Some("check the file path and try again"),
            );
            std::process::exit(1);
        }
    };

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

    apply_overrides(&mut scenario, overrides)?;

    let step_count = scenario.steps.len();
    if !scenario.meta.name.is_empty() {
        status::status(
            "Simulating",
            &format!("{} ({} steps)", scenario.meta.name, step_count),
        );
        if !scenario.meta.description.is_empty() {
            eprintln!("{:>12} {}", "", scenario.meta.description);
        }
        eprintln!();
    } else {
        status::status("Simulating", &format!("{path} ({step_count} steps)"));
        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 {
                status::error(&format!("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()?,
            "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(())
}