sim-cli 0.7.0

CLI tool for running and comparing Solana simulator backtests
Documentation
use chrono::{DateTime, Utc};
use clap::Parser;
use eyre::{Result, WrapErr};
use simulator_client::BacktestClient;

#[derive(Parser, Debug)]
pub struct UsageArgs {
    /// Report window start (RFC 3339 or YYYY-MM-DD). Defaults to the server's configured window.
    #[arg(long)]
    pub since: Option<String>,

    /// Report window end (RFC 3339 or YYYY-MM-DD). Defaults to now.
    #[arg(long)]
    pub until: Option<String>,

    /// Print output as JSON
    #[arg(long)]
    pub json: bool,
}

pub async fn usage(args: UsageArgs, url: String, api_key: String) -> Result<()> {
    let since = args
        .since
        .as_deref()
        .map(parse_datetime)
        .transpose()
        .wrap_err("invalid --since")?;
    let until = args
        .until
        .as_deref()
        .map(parse_datetime)
        .transpose()
        .wrap_err("invalid --until")?;

    let client = BacktestClient::builder().url(url).api_key(api_key).build();

    let report = client.usage(since, until).await?;

    if args.json {
        println!("{}", serde_json::to_string_pretty(&report)?);
        return Ok(());
    }

    println!("Usage Report");
    println!("  API key:      {}", report.api_key_name);
    println!(
        "  Time range:   {} to {}",
        report.since.format("%Y-%m-%d %H:%M:%S UTC"),
        report.until.format("%Y-%m-%d %H:%M:%S UTC"),
    );
    println!();
    println!("Sessions");
    println!("  Completed:  {}", report.sessions.completed);
    println!("  Incomplete: {}", report.sessions.failed);
    println!();
    println!("Compute");
    println!(
        "  Slots executed:         {}",
        report.compute.executed_slot_count
    );
    let total_mins = report.compute.session_duration_ms / 60_000;
    println!(
        "  Total session duration: {}h {}m",
        total_mins / 60,
        total_mins % 60,
    );

    Ok(())
}

fn parse_datetime(s: &str) -> Result<DateTime<Utc>> {
    // Try RFC 3339 first, then date-only (YYYY-MM-DD interpreted as midnight UTC).
    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
        return Ok(dt.with_timezone(&Utc));
    }
    let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
        .wrap_err_with(|| format!("cannot parse '{s}' as RFC 3339 or YYYY-MM-DD"))?;
    Ok(date.and_hms_opt(0, 0, 0).unwrap().and_utc())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rfc3339_offsets_normalize_to_utc() {
        assert_eq!(
            parse_datetime("2026-06-11T12:30:00+02:00").unwrap(),
            "2026-06-11T10:30:00Z".parse::<DateTime<Utc>>().unwrap()
        );
    }

    #[test]
    fn bare_dates_are_midnight_utc() {
        assert_eq!(
            parse_datetime("2026-06-11").unwrap(),
            "2026-06-11T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
        );
    }

    #[test]
    fn other_formats_are_rejected() {
        assert!(parse_datetime("June 11").is_err());
        assert!(parse_datetime("2026/06/11").is_err());
    }
}