diff --git a/Cargo.lock b/Cargo.lock index 0c360ac26f..b265699eb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2565,8 +2565,11 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", + "mio", "parking_lot", "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -5313,9 +5316,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "pkg-config", "vcpkg", @@ -5568,6 +5571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -6717,6 +6721,25 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "rain-interpreter-eval" +version = "0.1.0" +dependencies = [ + "alloy", + "eyre", + "foundry-evm", + "once_cell", + "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", + "rain_interpreter_bindings", + "reqwest 0.11.27", + "revm 24.0.1", + "revm 25.0.0", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen-utils 0.0.10 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "rain-math-float" version = "0.1.0" @@ -6814,6 +6837,51 @@ dependencies = [ "alloy", ] +[[package]] +name = "rain_interpreter_bindings" +version = "0.1.0" +dependencies = [ + "alloy", +] + +[[package]] +name = "rain_interpreter_dispair" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", + "rain_interpreter_bindings", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber 0.3.19", +] + +[[package]] +name = "rain_interpreter_parser" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", + "rain_interpreter_bindings", + "rain_interpreter_dispair", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rain_interpreter_test_fixtures" +version = "0.0.0" +dependencies = [ + "alloy", + "getrandom 0.2.16", + "serde_json", +] + [[package]] name = "rain_orderbook_app_settings" version = "0.0.0-alpha.0" @@ -6859,7 +6927,10 @@ dependencies = [ "chrono", "clap", "comfy-table", + "console", + "crossterm", "csv", + "dialoguer", "flate2", "futures", "httpmock", @@ -6869,10 +6940,11 @@ dependencies = [ "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", + "rain_orderbook_js_api", "rain_orderbook_quote", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", - "rainlang_bindings", + "reqwest 0.12.20", "rusqlite", "rust-bigint", "serde", @@ -6909,19 +6981,19 @@ dependencies = [ "once_cell", "proptest", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", + "rain-interpreter-eval", "rain-math-float", "rain-metaboard-subgraph", "rain-metadata 0.0.2-alpha.6", "rain-metadata-bindings", + "rain_interpreter_bindings", + "rain_interpreter_dispair", + "rain_interpreter_parser", "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_quote", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", - "rainlang-eval", - "rainlang_bindings", - "rainlang_dispair", - "rainlang_parser", "reqwest 0.12.20", "rusqlite", "serde", @@ -6931,7 +7003,6 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "strict-yaml-rust", - "tempfile", "thiserror 1.0.69", "tokio", "tower", @@ -7007,19 +7078,17 @@ dependencies = [ "anyhow", "async-trait", "clap", - "futures", "getrandom 0.2.16", "httpmock", "once_cell", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", + "rain-interpreter-eval", "rain-math-float", - "rain-metadata 0.0.2-alpha.6", "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", - "rainlang-eval", "reqwest 0.12.20", "serde", "serde_json", @@ -7082,60 +7151,10 @@ dependencies = [ "alloy", "getrandom 0.2.16", "rain-math-float", - "rainlang_test_fixtures", + "rain_interpreter_test_fixtures", "serde_json", ] -[[package]] -name = "rainlang-eval" -version = "0.1.0" -dependencies = [ - "alloy", - "eyre", - "foundry-evm", - "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", - "rainlang_bindings", - "revm 24.0.1", - "revm 25.0.0", - "serde", - "thiserror 1.0.69", - "wasm-bindgen-utils 0.0.10 (registry+https://github.com/rust-lang/crates.io-index)", -] - -[[package]] -name = "rainlang_bindings" -version = "0.1.0" -dependencies = [ - "alloy", -] - -[[package]] -name = "rainlang_dispair" -version = "0.1.0" -dependencies = [ - "alloy", -] - -[[package]] -name = "rainlang_parser" -version = "0.1.0" -dependencies = [ - "alloy", - "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", - "rainlang_bindings", - "rainlang_dispair", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "rainlang_test_fixtures" -version = "0.0.0" -dependencies = [ - "alloy", - "getrandom 0.2.16", -] - [[package]] name = "rand" version = "0.8.5" @@ -8030,9 +8049,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags 2.9.1", "fallible-iterator", @@ -8748,6 +8767,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 66a642500d..4bf8405871 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,6 +33,9 @@ url.workspace = true serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } +console = "0.15" +crossterm = "0.28" +dialoguer = "0.11" flate2 = "1.0.34" rusqlite = { version = "0.32", features = ["functions"] } diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs new file mode 100644 index 0000000000..79d51eda9b --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -0,0 +1,590 @@ +use super::select::{self, SelectContext, SelectItem}; +use alloy::primitives::hex; +use anyhow::{Context, Result}; +use console::Style; +use crossterm::{cursor, execute, terminal}; +use rain_orderbook_app_settings::order_builder::{ + OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, +}; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::io::{stderr, Write}; + +fn bold(text: &str) -> String { + Style::new().bold().apply_to(text).to_string() +} + +fn dim(text: &str) -> String { + Style::new().dim().apply_to(text).to_string() +} + +/// RAII guard that restores the terminal to a sane state (cooked mode, main +/// screen, visible cursor) on drop, even if a panic unwinds through us. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!(stderr(), terminal::LeaveAlternateScreen, cursor::Show); + let _ = terminal::disable_raw_mode(); + } +} + +/// Enter alternate screen once, run the entire wizard there, leave at the end. +pub async fn run_interactive(registry_url: &str) -> Result<()> { + eprintln!(" Fetching strategies from {}...", dim(registry_url)); + + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut w = stderr(); + terminal::enable_raw_mode()?; + execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; + let _guard = TerminalGuard; + + let mut progress: Vec = Vec::new(); + let result = run_wizard(&mut w, ®istry, &mut progress).await; + + match result { + Ok(output) => { + eprintln!(); + for line in &progress { + eprintln!(" {line}"); + } + eprintln!(); + + for line in &output { + println!("{line}"); + } + Ok(()) + } + Err(err) => Err(err), + } +} + +async fn run_wizard( + w: &mut impl Write, + registry: &DotrainRegistry, + progress: &mut Vec, +) -> Result> { + // 1. Owner + let owner = select::input( + w, + "Owner address", + Some("The wallet that will own this order and sign the deploy transactions."), + None, + false, + progress, + )?; + progress.push(format!("{}: {owner}", bold("Owner"))); + + // 2. Strategy + let (strategy_key, dotrain) = pick_strategy(w, registry, progress)?; + progress.push(format!("{}: {strategy_key}", bold("Strategy"))); + + let settings = registry_settings(registry); + + // 3. Deployment + let deployment_key = pick_deployment(w, &dotrain, &settings, progress)?; + progress.push(format!("{}: {deployment_key}", bold("Deployment"))); + + render_progress(w, progress, Some("Initializing builder..."))?; + + let mut builder = + RaindexOrderBuilder::new_with_deployment(dotrain, settings.clone(), deployment_key) + .await + .map_err(|err| { + anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) + })?; + + // 4. Token selection + if let Ok(tokens) = builder.get_select_tokens() { + if !tokens.is_empty() { + select_tokens(w, &mut builder, &tokens, progress).await?; + } + } + + // 5. Fields + fill_fields(w, &mut builder, progress)?; + + // 6. Deposits + fill_deposits(w, &mut builder, &owner, progress).await?; + + // 7. Generate calldata + render_progress(w, progress, Some("Generating calldata..."))?; + + let args = builder + .get_deployment_transaction_args(owner.clone()) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to generate deployment calldata: {}", + err.to_readable_msg() + ) + })?; + + progress.push(format!("{}: {}", bold("Chain"), args.chain_id)); + progress.push(format!("{}: {}", bold("Orderbook"), args.orderbook_address)); + + let mut calldata_lines = Vec::new(); + for approval in &args.approvals { + calldata_lines.push(format!( + "{}:0x{}", + approval.token, + hex::encode(&approval.calldata) + )); + progress.push(format!( + " Approve {} — {} bytes", + Style::new().cyan().apply_to(&approval.symbol), + approval.calldata.len() + )); + } + calldata_lines.push(format!( + "{}:0x{}", + args.orderbook_address, + hex::encode(&args.deployment_calldata) + )); + progress.push(format!( + " Deploy order — {} bytes", + args.deployment_calldata.len() + )); + if let Some(meta_call) = &args.emit_meta_call { + calldata_lines.push(format!( + "{}:0x{}", + meta_call.to, + hex::encode(&meta_call.calldata) + )); + progress.push(" Emit metadata".to_string()); + } + + // 8. Output choice + let output_items = vec![ + SelectItem { + key: "Print to stdout".to_string(), + description: "address:calldata lines".to_string(), + }, + SelectItem { + key: "Save to file".to_string(), + description: String::new(), + }, + ]; + let ctx = SelectContext::new(progress); + let output_choice = select::select(w, "Output", &output_items, &ctx)?; + + match output_choice { + 0 => Ok(calldata_lines), + 1 => { + let path = select::input( + w, + "Output file path", + None, + Some("deploy.calldata"), + false, + progress, + )?; + + let mut file = + std::fs::File::create(&path).with_context(|| format!("creating {path}"))?; + for line in &calldata_lines { + writeln!(file, "{line}")?; + } + progress.push(format!(" Wrote to {path}")); + Ok(Vec::new()) + } + other => unreachable!("unexpected output_choice index: {other}"), + } +} + +fn render_progress(w: &mut impl Write, progress: &[String], status: Option<&str>) -> Result<()> { + execute!( + w, + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All) + )?; + for line in progress { + write!(w, " {line}\r\n")?; + } + if let Some(msg) = status { + write!(w, "\r\n {msg}\r\n")?; + } + w.flush()?; + Ok(()) +} + +fn pick_strategy( + w: &mut impl Write, + registry: &DotrainRegistry, + progress: &[String], +) -> Result<(String, String)> { + let details = registry + .get_all_order_details() + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + if details.valid.is_empty() { + anyhow::bail!("no valid strategies found in registry"); + } + + let keys: Vec<&String> = details.valid.keys().collect(); + let select_items: Vec = keys + .iter() + .map(|key| { + let info = &details.valid[*key]; + SelectItem { + key: key.to_string(), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } + }) + .collect(); + + let ctx = SelectContext::new(progress); + let idx = select::select(w, "Strategy", &select_items, &ctx)?; + + let key = keys[idx].clone(); + let dotrain = registry + .orders() + .0 + .get(&key) + .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? + .clone(); + + Ok((key, dotrain)) +} + +fn pick_deployment( + w: &mut impl Write, + dotrain: &str, + settings: &Option>, + progress: &[String], +) -> Result { + let deployments = + RaindexOrderBuilder::get_deployment_details(dotrain.to_string(), settings.clone()) + .map_err(|err| { + anyhow::anyhow!( + "failed to get deployment details: {}", + err.to_readable_msg() + ) + })?; + + if deployments.is_empty() { + anyhow::bail!("no deployments found for this strategy"); + } + + if deployments.len() == 1 { + let (key, _) = deployments.into_iter().next().unwrap(); + return Ok(key); + } + + let keys: Vec<&String> = deployments.keys().collect(); + let select_items: Vec = keys + .iter() + .map(|key| { + let info = &deployments[*key]; + SelectItem { + key: format!("{} ({})", info.name, key), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } + }) + .collect(); + + let ctx = SelectContext::new(progress); + let idx = select::select(w, "Deployment", &select_items, &ctx)?; + let key = keys[idx].clone(); + Ok(key) +} + +async fn select_tokens( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + tokens: &[OrderBuilderSelectTokensCfg], + progress: &mut Vec, +) -> Result<()> { + for token_cfg in tokens { + let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); + + let available = builder.get_all_tokens(None).await.unwrap_or_default(); + + let address = if available.is_empty() { + select::input( + w, + &format!("{prompt_label} (address)"), + token_cfg.description.as_deref(), + None, + false, + progress, + )? + } else { + let mut select_items: Vec = available + .iter() + .map(|t| SelectItem { + key: format!("{} ({})", t.symbol, t.name), + description: format!("{}", t.address), + }) + .collect(); + select_items.push(SelectItem { + key: "Enter address manually".to_string(), + description: String::new(), + }); + + let mut ctx = SelectContext::new(progress); + if let Some(desc) = &token_cfg.description { + ctx = ctx.with_description(desc); + } + let idx = select::select(w, prompt_label, &select_items, &ctx)?; + + if idx < available.len() { + let token = &available[idx]; + progress.push(format!( + "{}: {} ({})", + bold(prompt_label), + token.symbol, + token.address + )); + format!("{}", token.address) + } else { + let addr = select::input( + w, + &format!("{prompt_label} address"), + None, + None, + false, + progress, + )?; + progress.push(format!("{}: {addr}", bold(prompt_label))); + addr + } + }; + + builder + .set_select_token(token_cfg.key.clone(), address) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to select token '{}': {}", + token_cfg.key, + err.to_readable_msg() + ) + })?; + } + + Ok(()) +} + +fn fill_fields( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + progress: &mut Vec, +) -> Result<()> { + let missing = builder + .get_missing_field_values() + .map_err(|err| anyhow::anyhow!("failed to get fields: {}", err.to_readable_msg()))?; + + if missing.is_empty() { + return Ok(()); + } + + for field in &missing { + fill_single_field(w, builder, field, progress)?; + } + + Ok(()) +} + +fn fill_single_field( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + field: &OrderBuilderFieldDefinitionCfg, + progress: &mut Vec, +) -> Result<()> { + let value = match &field.presets { + Some(presets) if !presets.is_empty() => { + let show_custom = field.show_custom_field.unwrap_or(true); + + let mut select_items: Vec = presets + .iter() + .map(|p| { + let label = p.name.as_deref().unwrap_or(&p.value); + SelectItem { + key: label.to_string(), + description: format!("= {}", p.value), + } + }) + .collect(); + + if show_custom { + select_items.push(SelectItem { + key: "Custom value".to_string(), + description: String::new(), + }); + } + + let mut ctx = SelectContext::new(progress); + if let Some(desc) = &field.description { + ctx = ctx.with_description(desc); + } + let idx = select::select(w, &field.name, &select_items, &ctx)?; + + if idx < presets.len() { + presets[idx].value.clone() + } else { + select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )? + } + } + _ => select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )?, + }; + + progress.push(format!("{}: {value}", bold(&field.name))); + + builder + .set_field_value(field.binding.clone(), value) + .map_err(|err| { + anyhow::anyhow!( + "failed to set field '{}': {}", + field.binding, + err.to_readable_msg() + ) + })?; + + Ok(()) +} + +async fn fill_deposits( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + owner: &str, + progress: &mut Vec, +) -> Result<()> { + let deployment = builder + .get_current_deployment() + .map_err(|err| anyhow::anyhow!("failed to get deployment: {}", err.to_readable_msg()))?; + + if deployment.deposits.is_empty() { + return Ok(()); + } + + for deposit_cfg in &deployment.deposits { + let (token_display, balance_desc) = + match builder.get_token_info(deposit_cfg.token_key.clone()).await { + Ok(info) => { + let balance = builder + .get_account_balance(format!("{}", info.address), owner.to_string()) + .await + .ok() + .map(|b| b.formatted_balance().to_string()); + let desc = balance + .map(|b| format!("Your balance: {b} {}", info.symbol)) + .unwrap_or_default(); + (info.symbol.clone(), desc) + } + Err(_) => (deposit_cfg.token_key.clone(), String::new()), + }; + + let presets = builder + .get_deposit_presets(deposit_cfg.token_key.clone()) + .unwrap_or_default(); + + let desc_opt = if balance_desc.is_empty() { + None + } else { + Some(balance_desc.as_str()) + }; + + let amount = if presets.is_empty() { + select::input( + w, + &format!("Deposit amount ({token_display}) — blank to skip"), + desc_opt, + None, + true, + progress, + )? + } else { + let mut select_items: Vec = presets + .iter() + .map(|p| SelectItem { + key: format!("{p} {token_display}"), + description: String::new(), + }) + .collect(); + select_items.push(SelectItem { + key: "Custom amount".to_string(), + description: String::new(), + }); + select_items.push(SelectItem { + key: "Skip".to_string(), + description: String::new(), + }); + + let title = format!("Deposit {token_display}"); + let mut ctx = SelectContext::new(progress); + if !balance_desc.is_empty() { + ctx = ctx.with_description(&balance_desc); + } + let idx = select::select(w, &title, &select_items, &ctx)?; + + if idx < presets.len() { + presets[idx].clone() + } else if idx == presets.len() { + select::input( + w, + &format!("Amount ({token_display})"), + None, + None, + false, + progress, + )? + } else { + continue; + } + }; + + if amount.is_empty() { + continue; + } + + progress.push(format!("{}: {amount} {token_display}", bold("Deposit"))); + + builder + .set_deposit(deposit_cfg.token_key.clone(), amount) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to set deposit '{}': {}", + deposit_cfg.token_key, + err.to_readable_msg() + ) + })?; + } + + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} diff --git a/crates/cli/src/commands/strategy_builder.rs b/crates/cli/src/commands/strategy_builder/mod.rs similarity index 86% rename from crates/cli/src/commands/strategy_builder.rs rename to crates/cli/src/commands/strategy_builder/mod.rs index 6add4f20ea..61a5c3a55e 100644 --- a/crates/cli/src/commands/strategy_builder.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,3 +1,6 @@ +mod interactive; +mod select; + use crate::execute::Execute; use alloy::primitives::hex; use anyhow::Result; @@ -14,14 +17,17 @@ pub struct StrategyBuilder { )] registry: String, + #[arg(short, long, help = "Interactive mode — guided strategy deployment")] + interactive: bool, + #[arg(long, help = "Order/strategy key from the registry")] - strategy: String, + strategy: Option, #[arg(long, help = "Deployment key within the strategy")] - deployment: String, + deployment: Option, #[arg(long, help = "Order owner address")] - owner: String, + owner: Option, #[arg( long = "set-field", @@ -65,6 +71,23 @@ fn parse_key_value_pairs(args: &[String]) -> Result> { impl Execute for StrategyBuilder { async fn execute(&self) -> Result<()> { + if self.interactive { + return interactive::run_interactive(&self.registry).await; + } + + let strategy = self + .strategy + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--strategy is required in non-interactive mode"))?; + let deployment = self + .deployment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--deployment is required in non-interactive mode"))?; + let owner = self + .owner + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--owner is required in non-interactive mode"))?; + let registry = DotrainRegistry::new(self.registry.clone()) .await .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; @@ -72,14 +95,12 @@ impl Execute for StrategyBuilder { let dotrain = registry .orders() .0 - .get(&self.strategy) + .get(strategy) .ok_or_else(|| { let mut available = registry.get_order_keys().unwrap_or_default(); available.sort(); anyhow::anyhow!( - "strategy '{}' not found in registry. Available: {:?}", - self.strategy, - available + "strategy '{strategy}' not found in registry. Available: {available:?}", ) })? .clone(); @@ -94,7 +115,7 @@ impl Execute for StrategyBuilder { }; let mut builder = - RaindexOrderBuilder::new_with_deployment(dotrain, settings, self.deployment.clone()) + RaindexOrderBuilder::new_with_deployment(dotrain, settings, deployment.clone()) .await .map_err(|err| { anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) @@ -130,7 +151,7 @@ impl Execute for StrategyBuilder { } let args = builder - .get_deployment_transaction_args(self.owner.clone()) + .get_deployment_transaction_args(owner.clone()) .await .map_err(|err| { anyhow::anyhow!( diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs new file mode 100644 index 0000000000..bc62fcc753 --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -0,0 +1,396 @@ +//! Select and Input widgets rendered directly to a writer. +//! The caller manages the alternate screen lifecycle. + +use anyhow::Result; +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; +use std::io::Write; + +const BOLD_UNDERLINE: &str = "\x1b[1;4m"; +const BOLD: &str = "\x1b[1m"; +const BOLD_CYAN: &str = "\x1b[1;36m"; +const DIM: &str = "\x1b[2m"; +const CYAN: &str = "\x1b[36m"; +const RESET: &str = "\x1b[0m"; + +pub struct SelectItem { + pub key: String, + pub description: String, +} + +/// Context shown above a prompt: prior selections and optional description. +pub struct SelectContext<'a> { + pub header_lines: &'a [String], + pub description: Option<&'a str>, +} + +impl<'a> SelectContext<'a> { + pub fn new(header_lines: &'a [String]) -> Self { + Self { + header_lines, + description: None, + } + } + + pub fn with_description(mut self, desc: &'a str) -> Self { + self.description = Some(desc); + self + } +} + +/// Word-wrap `text` to fit within `width` columns. Returns the number of lines. +fn wrap_lines(text: &str, width: usize) -> Vec { + if text.is_empty() || width == 0 { + return Vec::new(); + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current = word.to_string(); + } else if current.len() + 1 + word.len() <= width { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current = word.to_string(); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Write wrapped `text` with the given indent and ANSI style wrapper. +/// Returns number of lines written. +fn write_wrapped( + w: &mut impl Write, + text: &str, + indent: &str, + style: &str, + width: usize, +) -> Result { + let lines = wrap_lines(text, width); + for line in &lines { + write!(w, "{indent}{style}{line}{RESET}\r\n")?; + } + Ok(lines.len()) +} + +/// Write the header (prior selections) and return the number of rows used. +fn write_header(w: &mut impl Write, header_lines: &[String]) -> Result { + for line in header_lines { + write!(w, " {line}\r\n")?; + } + if !header_lines.is_empty() { + write!(w, "\r\n")?; + Ok(header_lines.len() + 1) + } else { + Ok(0) + } +} + +/// Write bold-underlined title + optional dim description. Returns rows used. +fn write_title( + w: &mut impl Write, + title: &str, + description: Option<&str>, + cols: usize, +) -> Result { + write!(w, " {BOLD_UNDERLINE}{title}{RESET}\r\n")?; + let mut rows = 1; + if let Some(desc) = description { + rows += write_wrapped(w, desc, " ", DIM, cols.saturating_sub(2))?; + } + write!(w, "\r\n")?; + Ok(rows + 1) +} + +/// Height (in rendered terminal rows) of a single select item. +fn item_height(item: &SelectItem, cols: usize) -> usize { + // " ❯ " prefix (4) + trailing margin (1) + let usable = cols.saturating_sub(5); + let desc_lines = if item.description.is_empty() { + 0 + } else { + wrap_lines(&item.description, usable).len() + }; + 1 + desc_lines + 1 // name + description + blank separator +} + +/// Count how many items fit starting at `offset`, given `max_rows` available. +fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize, cols: usize) -> usize { + let mut rows = 0; + let mut count = 0; + for item in items.iter().skip(offset) { + let h = item_height(item, cols); + if rows + h > max_rows { + break; + } + rows += h; + count += 1; + } + count.max(1) +} + +/// Text input rendered in the alt screen. +pub fn input( + w: &mut impl Write, + prompt: &str, + description: Option<&str>, + default: Option<&str>, + allow_empty: bool, + header_lines: &[String], +) -> Result { + let mut buffer = String::new(); + loop { + let (cols, _) = terminal::size()?; + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + write_header(w, header_lines)?; + write_title(w, prompt, description, cols as usize)?; + + write!(w, " > {CYAN}{buffer}{RESET}")?; + if buffer.is_empty() { + if let Some(d) = default { + write!(w, "{DIM}{d}{RESET}")?; + } + } + write!(w, "\x1b[?25h")?; // show cursor + w.flush()?; + + if let Event::Key(KeyEvent { + code, modifiers, .. + }) = event::read()? + { + match code { + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + anyhow::bail!("cancelled"); + } + KeyCode::Char(c) => buffer.push(c), + KeyCode::Backspace => { + buffer.pop(); + } + KeyCode::Enter => match (buffer.is_empty(), default, allow_empty) { + (true, Some(d), _) => return Ok(d.to_string()), + (true, None, true) => return Ok(String::new()), + (true, None, false) => {} // keep looping — require input + (false, _, _) => return Ok(buffer), + }, + KeyCode::Esc => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +/// Scrollable select list. +pub fn select( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + ctx: &SelectContext, +) -> Result { + if items.is_empty() { + anyhow::bail!("no items to select from"); + } + if items.len() == 1 { + return Ok(0); + } + + let mut selected = 0usize; + let mut scroll = 0usize; + + loop { + let (cols, rows) = terminal::size()?; + let cols = cols as usize; + let rows = rows as usize; + + let header_rows = render_select(w, title, items, selected, scroll, ctx)?; + let max_rows = rows.saturating_sub(header_rows + 2); + + if let Event::Key(KeyEvent { code, .. }) = event::read()? { + match code { + KeyCode::Up | KeyCode::Char('k') => { + if selected > 0 { + selected -= 1; + if selected < scroll { + scroll = selected; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if selected + 1 < items.len() { + selected += 1; + let fitting = count_items_fitting(items, scroll, max_rows, cols); + if selected >= scroll + fitting { + scroll += 1; + } + } + } + KeyCode::Enter => return Ok(selected), + KeyCode::Esc | KeyCode::Char('q') => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +/// Render the select list. Returns the number of rows used by the header +/// (everything above the items) so the caller can compute `max_rows`. +fn render_select( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + selected: usize, + scroll: usize, + ctx: &SelectContext, +) -> Result { + let (cols, rows) = terminal::size()?; + let cols = cols as usize; + let rows = rows as usize; + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + + let header_rows = write_header(w, ctx.header_lines)?; + let title_rows = write_title(w, title, ctx.description, cols)?; + let total_header = header_rows + title_rows; + let max_rows = rows.saturating_sub(total_header + 2); + + let mut rows_used = 0; + for (idx, item) in items.iter().enumerate().skip(scroll) { + let h = item_height(item, cols); + if rows_used + h > max_rows { + break; + } + + let is_selected = idx == selected; + let (prefix, style) = if is_selected { + ("❯ ", BOLD_CYAN) + } else { + (" ", BOLD) + }; + write!(w, " {prefix}{style}{}{RESET}\r\n", item.key)?; + if !item.description.is_empty() { + write_wrapped(w, &item.description, " ", DIM, cols.saturating_sub(5))?; + } + write!(w, "\r\n")?; + rows_used += h; + } + + // Scroll indicators + let xpos = cols.saturating_sub(3) as u16; + let ypos_bottom = rows.saturating_sub(2) as u16; + let ypos_hint = rows.saturating_sub(1) as u16; + if scroll > 0 { + execute!(w, cursor::MoveTo(xpos, total_header as u16))?; + write!(w, " ▲")?; + } + if scroll + count_items_fitting(items, scroll, max_rows, cols) < items.len() { + execute!(w, cursor::MoveTo(xpos, ypos_bottom))?; + write!(w, " ▼")?; + } + + // Bottom hint + execute!(w, cursor::MoveTo(0, ypos_hint))?; + write!(w, " {DIM}↑↓ navigate ⏎ select esc quit{RESET}")?; + + w.flush()?; + Ok(total_header) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_empty_text_returns_no_lines() { + assert!(wrap_lines("", 10).is_empty()); + } + + #[test] + fn wrap_short_text_returns_single_line() { + assert_eq!(wrap_lines("hello world", 20), vec!["hello world"]); + } + + #[test] + fn wrap_long_text_breaks_on_word_boundaries() { + let result = wrap_lines("one two three four five", 10); + assert_eq!(result, vec!["one two", "three four", "five"]); + } + + #[test] + fn wrap_very_long_word_goes_on_own_line() { + let result = wrap_lines("a supercalifragilistic b", 10); + assert_eq!(result, vec!["a", "supercalifragilistic", "b"]); + } + + #[test] + fn wrap_zero_width_returns_no_lines() { + assert!(wrap_lines("hello", 0).is_empty()); + } + + #[test] + fn item_height_with_empty_description() { + let item = SelectItem { + key: "key".into(), + description: String::new(), + }; + // name line + blank separator = 2 + assert_eq!(item_height(&item, 80), 2); + } + + #[test] + fn item_height_with_description_wraps() { + let item = SelectItem { + key: "key".into(), + description: "one two three four five six seven eight nine ten".into(), + }; + // usable width = 80 - 5 = 75 — fits on one line + assert_eq!(item_height(&item, 80), 3); + } + + #[test] + fn item_height_with_description_narrow_terminal() { + let item = SelectItem { + key: "key".into(), + description: "one two three four five six seven eight nine ten".into(), + }; + // usable = 20 - 5 = 15 — "one two three" (13), "four five six" (13), etc. + let h = item_height(&item, 20); + assert!(h > 3, "expected wrapped description, got height {h}"); + } + + #[test] + fn count_items_fitting_respects_max_rows() { + let items = vec![ + SelectItem { + key: "a".into(), + description: String::new(), + }, + SelectItem { + key: "b".into(), + description: String::new(), + }, + SelectItem { + key: "c".into(), + description: String::new(), + }, + ]; + // Each item = 2 rows; 5 rows max fits 2 items (4 rows), not 3 (6 rows) + assert_eq!(count_items_fitting(&items, 0, 5, 80), 2); + } + + #[test] + fn count_items_fitting_always_returns_at_least_one() { + let items = vec![SelectItem { + key: "a".into(), + description: "very long description that will wrap many times".into(), + }]; + // Even with max_rows = 1, we return 1 to avoid an empty list + assert_eq!(count_items_fitting(&items, 0, 1, 80), 1); + } +}