cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit eb2f23e69651ad16fb6550bb31de6d3de508c51e
parent 4a6618dc1528de5f09cd55434b9ad6524df6579a
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 21:53:31 +0000

implement human first order aliases and watch flow

Diffstat:
Msrc/cli.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/commands/order.rs | 230++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/domain/runtime.rs | 17+++++++++++++++++
Msrc/main.rs | 12+++++++++++-
Msrc/render/mod.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtests/help.rs | 1+
Mtests/order.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 480 insertions(+), 116 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -141,6 +141,7 @@ Examples: radroots order create --listing sf-tomatoes --bin bin-1 --qty 2 radroots order view ord_demo radroots order list + radroots order submit ord_demo --watch Compatibility aliases: new, get, ls. "; @@ -1106,6 +1107,8 @@ pub struct OrderNewArgs { #[derive(Debug, Clone, Args)] pub struct OrderSubmitArgs { pub key: String, + #[arg(long, action = ArgAction::SetTrue)] + pub watch: bool, #[arg(long)] pub idempotency_key: Option<String>, #[arg(long = "signer-session-id")] @@ -2047,6 +2050,15 @@ mod tests { _ => panic!("unexpected command variant"), } + let order_create = CliArgs::parse_from(["radroots", "order", "create"]); + match order_create.command { + Command::Order(args) => match args.command { + OrderCommand::New(_) => {} + _ => panic!("unexpected order subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let order_get = CliArgs::parse_from(["radroots", "order", "get", "ord_demo"]); match order_get.command { Command::Order(args) => match args.command { @@ -2056,6 +2068,15 @@ mod tests { _ => panic!("unexpected command variant"), } + let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]); + match order_view.command { + Command::Order(args) => match args.command { + OrderCommand::Get(key) => assert_eq!(key.key, "ord_demo"), + _ => panic!("unexpected order subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let order_ls = CliArgs::parse_from(["radroots", "order", "ls"]); match order_ls.command { Command::Order(args) => match args.command { @@ -2065,11 +2086,21 @@ mod tests { _ => panic!("unexpected command variant"), } + let order_list = CliArgs::parse_from(["radroots", "order", "list"]); + match order_list.command { + Command::Order(args) => match args.command { + OrderCommand::Ls => {} + _ => panic!("unexpected order subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let order_submit = CliArgs::parse_from([ "radroots", "order", "submit", "ord_demo", + "--watch", "--idempotency-key", "submit-1", "--signer-session-id", @@ -2079,6 +2110,7 @@ mod tests { Command::Order(args) => match args.command { OrderCommand::Submit(submit) => { assert_eq!(submit.key, "ord_demo"); + assert!(submit.watch); assert_eq!(submit.idempotency_key.as_deref(), Some("submit-1")); assert_eq!(submit.signer_session_id.as_deref(), Some("sess_456")); } @@ -2193,6 +2225,17 @@ mod tests { assert_eq!(sell_add.command.display_name(), "sell add"); assert!(!sell_add.command.supports_dry_run()); + let order_create = CliArgs::parse_from(["radroots", "order", "create"]); + assert_eq!(order_create.command.display_name(), "order create"); + assert!(!order_create.command.supports_dry_run()); + + let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]); + assert_eq!(order_view.command.display_name(), "order view"); + assert!(order_view.command.supports_dry_run()); + + let order_list = CliArgs::parse_from(["radroots", "order", "list"]); + assert_eq!(order_list.command.display_name(), "order list"); + assert!(order_list.command.supports_dry_run()); let order_watch = CliArgs::parse_from(["radroots", "order", "watch", "ord_demo"]); assert!( order_watch diff --git a/src/commands/order.rs b/src/commands/order.rs @@ -1,134 +1,148 @@ use crate::cli::{OrderNewArgs, OrderSubmitArgs, OrderWatchArgs, RecordKeyArgs}; -use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView}; +use crate::domain::runtime::{ + CommandDisposition, CommandOutput, CommandView, OrderSubmitView, OrderSubmitWatchView, +}; use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{ + CapabilityBindingTargetKind, OutputFormat, RuntimeConfig, WRITE_PLANE_TRADE_JSONRPC_CAPABILITY, +}; pub fn new(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::scaffold(config, args)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderNew(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderNew(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderNew(view)) - } - CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderNew(view)), - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderNew(view)) - } - }) + let mut view = crate::runtime::order::scaffold(config, args)?; + rewrite_order_actions(&mut view.actions); + Ok(command_output( + view.disposition(), + CommandView::OrderNew(view), + )) } pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::get(config, args)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderGet(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderGet(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderGet(view)) - } - CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderGet(view)), - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderGet(view)) - } - }) + let mut view = crate::runtime::order::get(config, args)?; + rewrite_order_actions(&mut view.actions); + Ok(command_output( + view.disposition(), + CommandView::OrderGet(view), + )) } pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::list(config)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderList(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderList(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderList(view)) - } - CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderList(view)), - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderList(view)) - } - }) + let mut view = crate::runtime::order::list(config)?; + rewrite_order_actions(&mut view.actions); + Ok(command_output( + view.disposition(), + CommandView::OrderList(view), + )) } pub fn submit( config: &RuntimeConfig, args: &OrderSubmitArgs, ) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::submit(config, args)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderSubmit(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderSubmit(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderSubmit(view)) - } - CommandDisposition::Unsupported => { - CommandOutput::unsupported(CommandView::OrderSubmit(view)) - } - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderSubmit(view)) - } - }) + let mut view = crate::runtime::order::submit(config, args)?; + rewrite_order_actions(&mut view.actions); + + if args.watch + && config.output.format == OutputFormat::Human + && should_watch_submitted_order(&view) + { + let watch_config = watch_runtime_config(config); + let mut watch = crate::runtime::order::watch( + &watch_config, + &OrderWatchArgs { + key: view.order_id.clone(), + frames: None, + interval_ms: 1_000, + }, + )?; + rewrite_order_actions(&mut watch.actions); + let combined = OrderSubmitWatchView { + submit: view, + watch, + }; + return Ok(command_output( + combined.disposition(), + CommandView::OrderSubmitWatch(combined), + )); + } + + Ok(command_output( + view.disposition(), + CommandView::OrderSubmit(view), + )) } pub fn watch(config: &RuntimeConfig, args: &OrderWatchArgs) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::watch(config, args)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderWatch(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderWatch(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderWatch(view)) - } - CommandDisposition::Unsupported => { - CommandOutput::unsupported(CommandView::OrderWatch(view)) - } - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderWatch(view)) - } - }) + let mut view = crate::runtime::order::watch(config, args)?; + rewrite_order_actions(&mut view.actions); + Ok(command_output( + view.disposition(), + CommandView::OrderWatch(view), + )) } pub fn cancel(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::cancel(config, args)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderCancel(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderCancel(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderCancel(view)) - } - CommandDisposition::Unsupported => { - CommandOutput::unsupported(CommandView::OrderCancel(view)) - } - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderCancel(view)) - } - }) + let mut view = crate::runtime::order::cancel(config, args)?; + rewrite_order_actions(&mut view.actions); + Ok(command_output( + view.disposition(), + CommandView::OrderCancel(view), + )) } pub fn history(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { - let view = crate::runtime::order::history(config)?; - Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::OrderHistory(view)), - CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::OrderHistory(view)) - } - CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::OrderHistory(view)) - } - CommandDisposition::Unsupported => { - CommandOutput::unsupported(CommandView::OrderHistory(view)) - } - CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::OrderHistory(view)) - } - }) + let mut view = crate::runtime::order::history(config)?; + rewrite_order_actions(&mut view.actions); + Ok(command_output( + view.disposition(), + CommandView::OrderHistory(view), + )) +} + +fn should_watch_submitted_order(view: &OrderSubmitView) -> bool { + !matches!( + view.state.as_str(), + "dry_run" | "error" | "missing" | "unavailable" | "unconfigured" + ) && view + .job + .as_ref() + .is_some_and(|job| job.job_id.as_str() != "not_submitted") +} + +fn watch_runtime_config(config: &RuntimeConfig) -> RuntimeConfig { + let mut watch_config = config.clone(); + if let Some(binding) = config.capability_binding(WRITE_PLANE_TRADE_JSONRPC_CAPABILITY) { + if binding.target_kind == CapabilityBindingTargetKind::ExplicitEndpoint { + watch_config.rpc.url = binding.target.clone(); + } + } + watch_config +} + +fn rewrite_order_actions(actions: &mut Vec<String>) { + for action in actions { + *action = rewrite_order_action(action.as_str()); + } +} + +fn rewrite_order_action(action: &str) -> String { + if action == "radroots order new" { + return "radroots order create".to_owned(); + } + if action == "radroots order ls" { + return "radroots order list".to_owned(); + } + if let Some(key) = action.strip_prefix("radroots order get ") { + return format!("radroots order view {key}"); + } + action.to_owned() +} + +fn command_output(disposition: CommandDisposition, view: CommandView) -> CommandOutput { + match disposition { + CommandDisposition::Success => CommandOutput::success(view), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(view), + CommandDisposition::ExternalUnavailable => CommandOutput::external_unavailable(view), + CommandDisposition::Unsupported => CommandOutput::unsupported(view), + CommandDisposition::InternalError => CommandOutput::internal_error(view), + } } diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -114,6 +114,7 @@ pub enum CommandView { OrderList(OrderListView), OrderNew(OrderNewView), OrderSubmit(OrderSubmitView), + OrderSubmitWatch(OrderSubmitWatchView), OrderWatch(OrderWatchView), RpcSessions(RpcSessionsView), RpcStatus(RpcStatusView), @@ -1236,6 +1237,22 @@ impl OrderSubmitView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderSubmitWatchView { + pub submit: OrderSubmitView, + pub watch: OrderWatchView, +} + +impl OrderSubmitWatchView { + pub fn disposition(&self) -> CommandDisposition { + let submit = self.submit.disposition(); + if submit != CommandDisposition::Success { + return submit; + } + self.watch.disposition() + } +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderWatchView { pub state: String, pub source: String, diff --git a/src/main.rs b/src/main.rs @@ -12,7 +12,7 @@ use std::process::ExitCode; use crate::cli::CliArgs; use crate::commands::dispatch; use crate::render::render_output; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{OutputFormat, RuntimeConfig}; use crate::runtime::logging::initialize_logging; fn main() -> ExitCode { @@ -39,6 +39,16 @@ fn validate_command_contracts( command: &crate::cli::Command, config: &RuntimeConfig, ) -> Result<(), runtime::RuntimeError> { + if let crate::cli::Command::Order(order) = command { + if let crate::cli::OrderCommand::Submit(args) = &order.command { + if args.watch && config.output.format != OutputFormat::Human { + return Err(runtime::RuntimeError::Config( + "`order submit --watch` only supports human output; use `order submit` and `order watch` for machine output".to_owned(), + )); + } + } + } + if !command.supports_output_format(config.output.format) { return Err(runtime::RuntimeError::Config(format!( "`{}` does not support --{}", diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -7,10 +7,10 @@ use crate::domain::runtime::{ ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, - OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, - RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SellAddView, - SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView, StatusView, - SyncActionView, SyncStatusView, SyncWatchView, + OrderSubmitWatchView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, + RpcStatusView, RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, + SellAddView, SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView, + StatusView, SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -101,6 +101,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::OrderSubmit(view) => { render_order_submit(stdout, view)?; } + CommandView::OrderSubmitWatch(view) => { + render_order_submit_watch(stdout, view)?; + } CommandView::OrderWatch(view) => { render_order_watch(stdout, view)?; } @@ -317,6 +320,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::OrderSubmitWatch(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::OrderWatch(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -1668,6 +1675,75 @@ fn render_order_submit(stdout: &mut dyn Write, view: &OrderSubmitView) -> Result Ok(()) } +fn render_order_submit_watch( + stdout: &mut dyn Write, + view: &OrderSubmitWatchView, +) -> Result<(), RuntimeError> { + writeln!(stdout, "{}", order_submit_watch_headline(&view.submit))?; + writeln!(stdout)?; + + writeln!(stdout, "Order")?; + let mut order_rows = vec![ + ("ID", view.submit.order_id.clone()), + ("State", humanize_machine_label(view.submit.state.as_str())), + ]; + push_row( + &mut order_rows, + "Listing", + first_present([ + view.submit.listing_lookup.as_deref(), + view.submit.listing_addr.as_deref(), + ]), + ); + push_row( + &mut order_rows, + "Buyer", + first_present([ + view.submit.buyer_account_id.as_deref(), + view.submit.buyer_pubkey.as_deref(), + ]), + ); + render_field_rows(stdout, order_rows.as_slice())?; + + if let Some(job) = &view.submit.job { + writeln!(stdout, "Job")?; + let mut job_rows = vec![ + ("Job", job.job_id.clone()), + ("State", humanize_machine_label(job.state.as_str())), + ]; + push_row(&mut job_rows, "Event", job.event_id.clone()); + render_field_rows(stdout, job_rows.as_slice())?; + } + + writeln!(stdout, "Watching order {}", view.watch.order_id)?; + + if view.watch.frames.is_empty() { + if let Some(reason) = &view.watch.reason { + writeln!(stdout)?; + writeln!(stdout, "{reason}")?; + } + } else { + for frame in &view.watch.frames { + writeln!(stdout)?; + writeln!( + stdout, + "{}", + crate::runtime::job::format_clock(frame.observed_at_unix) + )?; + let rows = vec![ + ("State", humanize_machine_label(frame.state.as_str())), + ("Summary", frame.summary.clone()), + ]; + render_field_rows(stdout, rows.as_slice())?; + } + } + + if !view.watch.actions.is_empty() { + render_item_section(stdout, "Next", &view.watch.actions)?; + } + Ok(()) +} + fn render_order_watch(stdout: &mut dyn Write, view: &OrderWatchView) -> Result<(), RuntimeError> { let context = match view.state.as_str() { "missing" => format!("order · {} watch missing", view.order_id), @@ -3206,6 +3282,28 @@ fn non_empty_str(value: &str) -> Option<&str> { } } +fn humanize_machine_label(value: &str) -> String { + value + .split('_') + .filter(|segment| !segment.is_empty()) + .map(capitalize_ascii_word) + .collect::<Vec<_>>() + .join(" ") +} + +fn order_submit_watch_headline(view: &OrderSubmitView) -> &'static str { + match view.state.as_str() { + "already_submitted" => "Order already submitted", + "deduplicated" => "Order already in progress", + "dry_run" => "Dry run only", + "error" => "Order submit failed", + "missing" => "Order draft not found", + "unavailable" => "Order submit unavailable", + "unconfigured" => "Not ready yet", + _ => "Order submitted", + } +} + fn humanize_delivery_method(value: &str) -> String { value .split('_') @@ -3620,11 +3718,12 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::MycStatus(_) => "myc status", CommandView::NetStatus(_) => "net status", CommandView::OrderCancel(_) => "order cancel", - CommandView::OrderGet(_) => "order get", + CommandView::OrderGet(_) => "order view", CommandView::OrderHistory(_) => "order history", - CommandView::OrderList(_) => "order ls", - CommandView::OrderNew(_) => "order new", + CommandView::OrderList(_) => "order list", + CommandView::OrderNew(_) => "order create", CommandView::OrderSubmit(_) => "order submit", + CommandView::OrderSubmitWatch(_) => "order submit --watch", CommandView::OrderWatch(_) => "order watch", CommandView::RpcSessions(_) => "rpc sessions", CommandView::RpcStatus(_) => "rpc status", diff --git a/tests/help.rs b/tests/help.rs @@ -122,5 +122,6 @@ fn order_help_prefers_create_view_and_list() { assert!(stdout.contains("watch")); assert!(stdout.contains("cancel")); assert!(stdout.contains("history")); + assert!(stdout.contains("radroots order submit ord_demo --watch")); assert!(stdout.contains("Compatibility aliases: new, get, ls.")); } diff --git a/tests/order.rs b/tests/order.rs @@ -484,6 +484,79 @@ fn order_get_and_ls_read_local_drafts_and_report_missing() { } #[test] +fn order_create_view_and_list_aliases_wrap_the_existing_draft_surfaces() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let account_output = order_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + + let create_output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "create", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + "--qty", + "2", + ]) + .output() + .expect("run order create"); + assert!(create_output.status.success()); + let create_json: Value = + serde_json::from_slice(create_output.stdout.as_slice()).expect("create json"); + let order_id = create_json["order_id"].as_str().expect("order id"); + assert_eq!(create_json["state"], "draft_created"); + assert_eq!( + create_json["actions"][0], + format!("radroots order view {order_id}") + ); + + let view_output = order_command_in(dir.path()) + .args(["--json", "order", "view", order_id]) + .output() + .expect("run order view"); + assert!(view_output.status.success()); + let view_json: Value = + serde_json::from_slice(view_output.stdout.as_slice()).expect("view json"); + assert_eq!(view_json["state"], "ready"); + assert_eq!(view_json["order_id"], order_id); + + let list_output = order_command_in(dir.path()) + .args(["--json", "order", "list"]) + .output() + .expect("run order list"); + assert!(list_output.status.success()); + let list_json: Value = + serde_json::from_slice(list_output.stdout.as_slice()).expect("list json"); + assert_eq!(list_json["count"], 1); + assert_eq!(list_json["orders"][0]["id"], order_id); +} + +#[test] +fn order_list_empty_prefers_the_create_follow_up() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let output = order_command_in(dir.path()) + .args(["--json", "order", "list"]) + .output() + .expect("run empty order list"); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("list json"); + assert_eq!(json["state"], "empty"); + assert_eq!(json["actions"][0], "radroots order create"); +} + +#[test] fn order_get_surfaces_recorded_job_metadata_from_the_local_draft_store() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir"); @@ -653,6 +726,113 @@ fn order_submit_persists_submission_metadata_and_reports_job() { } #[test] +fn order_submit_watch_rejects_json_output() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let output = order_command_in(dir.path()) + .args(["--json", "order", "submit", "ord_demo", "--watch"]) + .output() + .expect("run order submit watch json"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); + assert!(stderr.contains("`order submit --watch` only supports human output")); +} + +#[test] +fn order_submit_watch_appends_human_watch_snapshots() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + let account_output = order_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let buyer_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey") + .to_owned(); + + let create_output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "create", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + ]) + .output() + .expect("run order create"); + assert!(create_output.status.success()); + let create_json: Value = + serde_json::from_slice(create_output.stdout.as_slice()).expect("create json"); + let order_id = create_json["order_id"].as_str().expect("order id"); + + let server = MockRpcServer::start(move |body, _auth_header| { + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_order_watch_01", + buyer_pubkey.as_str(), + &["sign_event"], + true + )])), + "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ + "deduplicated": false, + "job": sample_bridge_job( + "job_order_watch_01", + "accepted", + false, + "sess_order_watch_01" + ), + })), + "bridge.job.status" => MockRpcResponse::success(sample_bridge_job( + "job_order_watch_01", + "completed", + true, + "sess_order_watch_01", + )), + other => panic!("unexpected mock rpc method {other}"), + } + }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); + + let output = order_command_in(dir.path()) + .env("RADROOTS_RPC_BEARER_TOKEN", "watch-token") + .args([ + "order", + "submit", + order_id, + "--watch", + "--signer-session-id", + "sess_order_watch_01", + ]) + .output() + .expect("run order submit watch"); + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); + assert!( + output.status.success(), + "status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status.code() + ); + assert!(stdout.contains("Order submitted")); + assert!(stdout.contains("Watching order")); + assert!(stdout.contains(order_id)); + assert!(stdout.contains("Completed")); + assert!(stdout.contains("submitted to 2 relays")); + assert!(!stdout.contains("order ·")); +} + +#[test] fn order_watch_reports_job_frames_for_submitted_order() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir");