use std::time::Duration;
use clap::Parser;
use eyre::Result;
use sim_cli::ApiKeyArg;
use simulator_api::{BundleBuildRequest, BundleBuildStatus, BundleBuildStatusResponse};
use simulator_client::BacktestClient;
#[derive(Parser, Debug)]
pub struct BuildBundleArgs {
pub start_slot: u64,
pub end_slot: u64,
#[command(flatten)]
pub api_key: ApiKeyArg,
#[arg(long)]
pub bundle_size: Option<u64>,
#[arg(long)]
pub idempotency_key: Option<String>,
#[arg(long)]
pub wait: bool,
#[arg(long, default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
pub poll_secs: u64,
#[arg(long)]
pub json: bool,
}
pub async fn build_bundle(args: BuildBundleArgs, url: String) -> Result<()> {
let client = BacktestClient::builder()
.url(url)
.api_key(args.api_key.get())
.build();
let mut resp = client
.build_bundle(&BundleBuildRequest {
start_slot: args.start_slot,
end_slot: args.end_slot,
bundle_size: args.bundle_size,
idempotency_key: args.idempotency_key,
})
.await?;
if !args.json {
println!("{}", format_status("issued", &resp));
}
if args.wait {
while matches!(resp.status, BundleBuildStatus::InProgress) {
tokio::time::sleep(Duration::from_secs(args.poll_secs)).await;
resp = client.get_bundle_status(&resp.request_id).await?;
if !args.json {
println!("{}", format_status("status", &resp));
}
}
}
if args.json {
println!("{}", serde_json::to_string_pretty(&resp)?);
}
Ok(())
}
#[derive(Parser, Debug)]
pub struct BuildStatusArgs {
pub request_id: String,
#[command(flatten)]
pub api_key: ApiKeyArg,
#[arg(long)]
pub json: bool,
}
pub async fn get_status(args: BuildStatusArgs, url: String) -> Result<()> {
let client = BacktestClient::builder()
.url(url)
.api_key(args.api_key.get())
.build();
let resp = client.get_bundle_status(&args.request_id).await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&resp)?);
} else {
println!("{}", format_status("status", &resp));
}
Ok(())
}
fn format_status(label: &str, resp: &BundleBuildStatusResponse) -> String {
let size = resp
.bundle_size
.map_or_else(|| "default".to_string(), |s| s.to_string());
let eta = match resp.status {
BundleBuildStatus::InProgress => format!(
" eta {}",
format_duration(estimated_build_secs(resp.start_slot, resp.end_slot))
),
_ => String::new(),
};
format!(
"{label}: {} [{}] slots {}..={} size={size}{eta}",
resp.request_id,
resp.status.as_str(),
resp.start_slot,
resp.end_slot,
)
}
fn estimated_build_secs(start_slot: u64, end_slot: u64) -> u64 {
let slots = end_slot.saturating_sub(start_slot) as f64;
let snapshot_secs = 30 * 60;
snapshot_secs + (slots * 0.6) as u64 + (slots * 0.01) as u64
}
fn format_duration(secs: u64) -> String {
let (hours, minutes) = (secs / 3600, (secs % 3600) / 60);
if hours > 0 {
format!("~{hours}h{minutes:02}m")
} else {
format!("~{minutes}m")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_positional_range_and_flags() {
let args = BuildBundleArgs::try_parse_from([
"build-bundle",
"100",
"200",
"--api-key",
"test-key",
"--bundle-size",
"500",
"--wait",
])
.unwrap();
assert_eq!((args.start_slot, args.end_slot), (100, 200));
assert_eq!(args.bundle_size, Some(500));
assert!(args.wait);
}
#[test]
fn poll_secs_rejects_zero() {
assert!(
BuildBundleArgs::try_parse_from([
"build-bundle",
"1",
"2",
"--api-key",
"k",
"--wait",
"--poll-secs",
"0",
])
.is_err()
);
}
#[test]
fn formats_status_with_default_size() {
let resp = BundleBuildStatusResponse {
request_id: "req-1".to_string(),
start_slot: 1,
end_slot: 2,
bundle_size: None,
status: BundleBuildStatus::InProgress,
};
let line = format_status("issued", &resp);
assert!(line.contains("req-1"));
assert!(line.contains("in_progress"));
assert!(line.contains("size=default"));
}
}