mod client;
mod filter;
mod output;
mod snapshot;
mod store;
#[cfg(feature = "tui")]
mod tui;
use anyhow::{bail, Context, Result};
use clap::Args;
use hyperstack_sdk::Subscription;
use crate::config::HyperstackConfig;
#[derive(Args)]
pub struct StreamArgs {
pub view: Option<String>,
#[arg(short, long)]
pub key: Option<String>,
#[arg(long)]
pub url: Option<String>,
#[arg(short, long)]
pub stack: Option<String>,
#[arg(long)]
pub raw: bool,
#[arg(long)]
pub no_dna: bool,
#[arg(long = "where", value_name = "EXPR")]
pub filters: Vec<String>,
#[arg(long)]
pub select: Option<String>,
#[arg(long)]
pub first: bool,
#[arg(long)]
pub ops: Option<String>,
#[arg(long)]
pub count: bool,
#[arg(long)]
pub take: Option<u32>,
#[arg(long)]
pub skip: Option<u32>,
#[arg(long)]
pub no_snapshot: bool,
#[arg(long)]
pub after: Option<String>,
#[arg(long)]
pub save: Option<String>,
#[arg(long)]
pub duration: Option<u64>,
#[arg(
long,
conflicts_with = "url",
conflicts_with = "tui",
conflicts_with = "duration"
)]
pub load: Option<String>,
#[arg(long)]
pub history: bool,
#[arg(long)]
pub at: Option<usize>,
#[arg(long)]
pub diff: bool,
#[arg(long, short = 'i')]
pub tui: bool,
}
pub fn run(args: StreamArgs, config_path: &str) -> Result<()> {
if let Some(load_path) = &args.load {
let player = snapshot::SnapshotPlayer::load(load_path)?;
let default_view = player.header.view.clone();
let view = args.view.as_deref().unwrap_or(&default_view);
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
return rt.block_on(client::replay(player, view, &args));
}
let view = match args.view.as_deref() {
Some(v) => v,
None => bail!("<VIEW> argument is required (e.g. OreRound/latest)"),
};
let url = resolve_url(&args, config_path, view)?;
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
if args.tui {
if args.duration.is_some() {
bail!("--duration has no effect in TUI mode; stop with 'q' or Ctrl+C.");
}
if args.count {
bail!("--count is incompatible with TUI mode.");
}
if args.save.is_some() {
bail!("--save is not yet supported in TUI mode; use 's' inside the TUI to save.");
}
if args.history || args.at.is_some() || args.diff {
bail!("--history/--at/--diff are not supported in TUI mode; use h/l keys to browse history.");
}
if args.raw {
bail!("--raw is incompatible with TUI mode; omit --tui to use raw output.");
}
if args.no_dna {
bail!("--no-dna is incompatible with TUI mode; omit --tui to use NO_DNA output.");
}
if !args.filters.is_empty() {
bail!("--where is not supported in TUI mode; use '/' inside the TUI to filter.");
}
if args.select.is_some() {
bail!("--select is not supported in TUI mode.");
}
if args.ops.is_some() {
bail!("--ops is not supported in TUI mode.");
}
if args.first {
bail!("--first is not supported in TUI mode.");
}
#[cfg(feature = "tui")]
{
return rt.block_on(tui::run_tui(url, view, &args));
}
#[cfg(not(feature = "tui"))]
{
bail!(
"TUI mode requires the 'tui' feature.\n\
Install with: cargo install hyperstack-cli --features tui"
);
}
}
eprintln!("Connecting to {} ...", url);
rt.block_on(client::stream(url, view, &args))
}
pub fn build_subscription(view: &str, args: &StreamArgs) -> Subscription {
let mut sub = Subscription::new(view);
if let Some(key) = &args.key {
sub = sub.with_key(key.clone());
}
if let Some(take) = args.take {
sub = sub.with_take(take);
}
if let Some(skip) = args.skip {
sub = sub.with_skip(skip);
}
if args.no_snapshot {
sub = sub.with_snapshot(false);
}
if let Some(after) = &args.after {
sub = sub.after(after.clone());
}
sub
}
fn validate_ws_url(url: &str) -> Result<()> {
if !url.starts_with("ws://") && !url.starts_with("wss://") {
bail!("Invalid URL scheme. Expected ws:// or wss://, got: {}", url);
}
Ok(())
}
fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result<String> {
if let Some(url) = &args.url {
validate_ws_url(url)?;
return Ok(url.clone());
}
let config = HyperstackConfig::load_optional(config_path)?;
if let Some(stack_name) = &args.stack {
if let Some(config) = &config {
if let Some(stack) = config.find_stack(stack_name) {
if let Some(url) = &stack.url {
validate_ws_url(url)?;
return Ok(url.clone());
}
bail!(
"Stack '{}' found in config but has no url set.\n\
Set it in hyperstack.toml or use --url to specify the WebSocket URL.",
stack_name
);
}
}
bail!(
"Stack '{}' not found in {}.\n\
Available stacks: {}",
stack_name,
config_path,
list_stacks(config.as_ref()),
);
}
let entity_name = view.split('/').next().unwrap_or(view);
if let Some(config) = &config {
if let Some(stack) = config.find_stack(entity_name) {
if let Some(url) = &stack.url {
validate_ws_url(url)?;
return Ok(url.clone());
}
}
let stacks_with_urls: Vec<_> = config.stacks.iter().filter(|s| s.url.is_some()).collect();
if stacks_with_urls.len() == 1 {
let stack = stacks_with_urls[0];
let name = stack.name.as_deref().unwrap_or(&stack.stack);
let url = stack.url.clone().unwrap();
validate_ws_url(&url)?;
eprintln!("Using stack '{}' (only stack with a URL)", name);
return Ok(url);
}
}
bail!(
"Could not determine WebSocket URL.\n\n\
Specify one of:\n \
--url wss://your-stack.stack.usehyperstack.com\n \
--stack <name> (resolves from hyperstack.toml)\n\n\
Available stacks: {}",
list_stacks(config.as_ref()),
)
}
fn list_stacks(config: Option<&HyperstackConfig>) -> String {
match config {
Some(config) if !config.stacks.is_empty() => config
.stacks
.iter()
.map(|s| s.name.as_deref().unwrap_or(&s.stack).to_string())
.collect::<Vec<_>>()
.join(", "),
_ => "(none — create hyperstack.toml with [[stacks]] entries)".to_string(),
}
}