cli

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

commit c5a3e754be55bcbdcb695047071bea119a244a25
parent f8f7d29b8300a0567dfe2ba688b132615c0134e2
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 07:38:21 +0000

land order draft and inspection surfaces

Diffstat:
Msrc/cli.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/commands/mod.rs | 7++++---
Asrc/commands/order.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/runtime.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 241++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/mod.rs | 1+
Asrc/runtime/order.rs | 763+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/order.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1475 insertions(+), 11 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -103,7 +103,7 @@ impl Command { NetCommand::Status => "net status", }, Self::Order(order) => match order.command { - OrderCommand::New => "order new", + OrderCommand::New(_) => "order new", OrderCommand::Get(_) => "order get", OrderCommand::Ls => "order ls", OrderCommand::Submit => "order submit", @@ -166,7 +166,7 @@ impl Command { }) | Self::Listing(ListingArgs { command: ListingCommand::New(_), }) | Self::Order(OrderArgs { - command: OrderCommand::New | OrderCommand::Submit | OrderCommand::Cancel(_), + command: OrderCommand::New(_) | OrderCommand::Submit | OrderCommand::Cancel(_), }) ) } @@ -397,7 +397,7 @@ pub struct OrderArgs { #[derive(Debug, Clone, Subcommand)] pub enum OrderCommand { - New, + New(OrderNewArgs), Get(RecordKeyArgs), Ls, Submit, @@ -406,6 +406,18 @@ pub enum OrderCommand { History, } +#[derive(Debug, Clone, Args, Default)] +pub struct OrderNewArgs { + #[arg(long)] + pub listing: Option<String>, + #[arg(long = "listing-addr")] + pub listing_addr: Option<String>, + #[arg(long = "bin")] + pub bin_id: Option<String>, + #[arg(long = "qty")] + pub bin_count: Option<u32>, +} + #[derive(Debug, Clone, Args)] pub struct RecordKeyArgs { pub key: String, @@ -761,10 +773,48 @@ mod tests { _ => panic!("unexpected command variant"), } - let order = CliArgs::parse_from(["radroots", "order", "history"]); - match order.command { + let order_new = CliArgs::parse_from([ + "radroots", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + "--qty", + "2", + ]); + match order_new.command { + Command::Order(args) => match args.command { + OrderCommand::New(new) => { + assert_eq!(new.listing.as_deref(), Some("pasture-eggs")); + assert_eq!( + new.listing_addr.as_deref(), + Some("30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!(new.bin_id.as_deref(), Some("bin-1")); + assert_eq!(new.bin_count, Some(2)); + } + _ => 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 { + 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 { - OrderCommand::History => {} + OrderCommand::Ls => {} _ => panic!("unexpected order subcommand"), }, _ => panic!("unexpected command variant"), diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod listing; pub mod local; pub mod myc; pub mod net; +pub mod order; pub mod relay; pub mod rpc; pub mod runtime; @@ -73,9 +74,9 @@ pub fn dispatch( NetCommand::Status => net::status(config), }, Command::Order(order) => match &order.command { - OrderCommand::New => unimplemented_command("order new"), - OrderCommand::Get(_) => unimplemented_command("order get"), - OrderCommand::Ls => unimplemented_command("order ls"), + OrderCommand::New(args) => order::new(config, args), + OrderCommand::Get(args) => order::get(config, args), + OrderCommand::Ls => order::list(config), OrderCommand::Submit => unimplemented_command("order submit"), OrderCommand::Watch(_) => unimplemented_command("order watch"), OrderCommand::Cancel(_) => unimplemented_command("order cancel"), diff --git a/src/commands/order.rs b/src/commands/order.rs @@ -0,0 +1,52 @@ +use crate::cli::{OrderNewArgs, RecordKeyArgs}; +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +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::InternalError => { + CommandOutput::internal_error(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::InternalError => { + CommandOutput::internal_error(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::InternalError => { + CommandOutput::internal_error(CommandView::OrderList(view)) + } + }) +} diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -88,6 +88,9 @@ pub enum CommandView { LocalStatus(LocalStatusView), MycStatus(MycStatusView), NetStatus(NetStatusView), + OrderGet(OrderGetView), + OrderList(OrderListView), + OrderNew(OrderNewView), RpcSessions(RpcSessionsView), RpcStatus(RpcStatusView), RelayList(RelayListView), @@ -459,6 +462,149 @@ pub struct JobWatchFrameView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderNewView { + pub state: String, + pub source: String, + pub order_id: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_lookup: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + pub ready_for_submit: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec<OrderDraftItemView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderNewView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderGetView { + pub state: String, + pub source: String, + pub lookup: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub order_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_lookup: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + pub ready_for_submit: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec<OrderDraftItemView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at_unix: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job: Option<OrderJobView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderGetView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderListView { + pub state: String, + pub source: String, + pub count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub orders: Vec<OrderSummaryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderListView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderSummaryView { + pub id: String, + pub state: String, + pub ready_for_submit: bool, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_lookup: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_account_id: Option<String>, + pub item_count: usize, + pub updated_at_unix: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub job: Option<OrderJobView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderDraftItemView { + pub bin_id: String, + pub bin_count: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderIssueView { + pub field: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderJobView { + pub job_id: String, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct ListingNewView { pub state: String, pub source: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -4,8 +4,9 @@ use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, FindView, JobGetView, JobListView, JobWatchView, ListingGetView, ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, - LocalStatusView, NetStatusView, RelayListView, RpcSessionsView, RpcStatusView, SyncActionView, - SyncStatusView, SyncWatchView, + LocalStatusView, NetStatusView, OrderDraftItemView, OrderGetView, OrderJobView, OrderListView, + OrderNewView, RelayListView, RpcSessionsView, RpcStatusView, SyncActionView, SyncStatusView, + SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -78,6 +79,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::NetStatus(view) => { render_net_status(stdout, view)?; } + CommandView::OrderGet(view) => { + render_order_get(stdout, view)?; + } + CommandView::OrderList(view) => { + render_order_list(stdout, view)?; + } + CommandView::OrderNew(view) => { + render_order_new(stdout, view)?; + } CommandView::RpcSessions(view) => { render_rpc_sessions(stdout, view)?; } @@ -208,6 +218,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::OrderGet(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::OrderList(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::OrderNew(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::RpcSessions(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -342,6 +364,13 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<() } Ok(()) } + CommandView::OrderList(view) => { + for order in &view.orders { + serde_json::to_writer(&mut *stdout, order)?; + writeln!(stdout)?; + } + Ok(()) + } CommandView::RpcSessions(view) => { for session in &view.sessions { serde_json::to_writer(&mut *stdout, session)?; @@ -718,6 +747,211 @@ fn render_job_watch(stdout: &mut dyn Write, view: &JobWatchView) -> Result<(), R Ok(()) } +fn render_order_new(stdout: &mut dyn Write, view: &OrderNewView) -> Result<(), RuntimeError> { + write_context(stdout, "order · draft created")?; + let mut rows = vec![ + ("order id", view.order_id.as_str()), + ("file", view.file.as_str()), + ("ready for submit", yes_no(view.ready_for_submit)), + ]; + if let Some(listing_lookup) = &view.listing_lookup { + rows.push(("listing", listing_lookup.as_str())); + } + if let Some(listing_addr) = &view.listing_addr { + rows.push(("listing addr", listing_addr.as_str())); + } + if let Some(account_id) = &view.buyer_account_id { + rows.push(("buyer account", account_id.as_str())); + } + if let Some(buyer_pubkey) = &view.buyer_pubkey { + rows.push(("buyer pubkey", buyer_pubkey.as_str())); + } + if let Some(seller_pubkey) = &view.seller_pubkey { + rows.push(("seller pubkey", seller_pubkey.as_str())); + } + render_pairs(stdout, "draft", rows.as_slice())?; + render_order_items(stdout, &view.items)?; + render_order_issues(stdout, &view.issues)?; + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_order_get(stdout: &mut dyn Write, view: &OrderGetView) -> Result<(), RuntimeError> { + let context = match view.state.as_str() { + "missing" => format!("order · {} missing", view.lookup), + "submitted" => format!("order · {} submitted", view.lookup), + "ready" => format!("order · {} ready", view.lookup), + "draft" => format!("order · {} draft", view.lookup), + "error" => format!("order · {} error", view.lookup), + _ => format!("order · {}", view.lookup), + }; + write_context(stdout, context.as_str())?; + + if view.state == "missing" || view.state == "error" { + if let Some(reason) = &view.reason { + writeln!(stdout, "{reason}")?; + writeln!(stdout)?; + } + if let Some(file) = &view.file { + writeln!(stdout, "file: {file}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + return Ok(()); + } + + let mut rows = Vec::<(&str, &str)>::new(); + if let Some(order_id) = &view.order_id { + rows.push(("order id", order_id.as_str())); + } + if let Some(file) = &view.file { + rows.push(("file", file.as_str())); + } + rows.push(("ready for submit", yes_no(view.ready_for_submit))); + if let Some(listing_lookup) = &view.listing_lookup { + rows.push(("listing", listing_lookup.as_str())); + } + if let Some(listing_addr) = &view.listing_addr { + rows.push(("listing addr", listing_addr.as_str())); + } + if let Some(account_id) = &view.buyer_account_id { + rows.push(("buyer account", account_id.as_str())); + } + if let Some(buyer_pubkey) = &view.buyer_pubkey { + rows.push(("buyer pubkey", buyer_pubkey.as_str())); + } + if let Some(seller_pubkey) = &view.seller_pubkey { + rows.push(("seller pubkey", seller_pubkey.as_str())); + } + render_pairs(stdout, "order", rows.as_slice())?; + if let Some(updated_at_unix) = view.updated_at_unix { + writeln!( + stdout, + "updated: {}", + crate::runtime::job::format_timestamp(updated_at_unix) + )?; + } + render_order_items(stdout, &view.items)?; + if let Some(job) = &view.job { + render_order_job(stdout, job)?; + } + render_order_issues(stdout, &view.issues)?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_order_list(stdout: &mut dyn Write, view: &OrderListView) -> Result<(), RuntimeError> { + let context = match view.state.as_str() { + "empty" => "orders · no local drafts".to_owned(), + "degraded" => format!("orders · {} local drafts with issues", view.count), + _ => format!( + "orders · {} local draft{}", + view.count, + if view.count == 1 { "" } else { "s" } + ), + }; + write_context(stdout, context.as_str())?; + if view.orders.is_empty() { + writeln!(stdout, "no order drafts found")?; + writeln!(stdout)?; + } else { + let table = Table { + headers: &["order", "listing", "state", "ready", "job", "updated"], + rows: view + .orders + .iter() + .map(|order| { + vec![ + order.id.clone(), + order + .listing_lookup + .clone() + .or_else(|| order.listing_addr.clone()) + .unwrap_or_default(), + order.state.clone(), + yes_no(order.ready_for_submit).to_owned(), + order + .job + .as_ref() + .map(|job| job.state.clone()) + .unwrap_or_default(), + crate::runtime::job::format_timestamp(order.updated_at_unix), + ] + }) + .collect(), + }; + render_table(stdout, &table)?; + writeln!(stdout)?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_order_items( + stdout: &mut dyn Write, + items: &[OrderDraftItemView], +) -> Result<(), RuntimeError> { + if items.is_empty() { + writeln!(stdout, "items: no line items yet")?; + writeln!(stdout)?; + return Ok(()); + } + + let table = Table { + headers: &["bin", "qty"], + rows: items + .iter() + .map(|item| vec![item.bin_id.clone(), item.bin_count.to_string()]) + .collect(), + }; + render_table(stdout, &table)?; + writeln!(stdout)?; + Ok(()) +} + +fn render_order_job(stdout: &mut dyn Write, job: &OrderJobView) -> Result<(), RuntimeError> { + let mut rows = vec![ + ("job id", job.job_id.as_str()), + ("state", job.state.as_str()), + ]; + if let Some(command) = &job.command { + rows.push(("command", command.as_str())); + } + if let Some(event_id) = &job.event_id { + rows.push(("event id", event_id.as_str())); + } + if let Some(event_addr) = &job.event_addr { + rows.push(("event addr", event_addr.as_str())); + } + render_pairs(stdout, "job", rows.as_slice())?; + if let Some(reason) = &job.reason { + writeln!(stdout, "job reason: {reason}")?; + } + Ok(()) +} + +fn render_order_issues( + stdout: &mut dyn Write, + issues: &[crate::domain::runtime::OrderIssueView], +) -> Result<(), RuntimeError> { + if issues.is_empty() { + return Ok(()); + } + + writeln!(stdout, "issues")?; + for issue in issues { + writeln!(stdout, " {} {}", issue.field, issue.message)?; + } + writeln!(stdout)?; + Ok(()) +} + fn render_listing_new(stdout: &mut dyn Write, view: &ListingNewView) -> Result<(), RuntimeError> { write_context(stdout, "listing · draft created")?; let mut rows = vec![ @@ -1541,6 +1775,9 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::LocalStatus(_) => "local status", CommandView::MycStatus(_) => "myc status", CommandView::NetStatus(_) => "net status", + CommandView::OrderGet(_) => "order get", + CommandView::OrderList(_) => "order ls", + CommandView::OrderNew(_) => "order new", CommandView::RpcSessions(_) => "rpc sessions", CommandView::RpcStatus(_) => "rpc status", CommandView::RelayList(_) => "relay ls", diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -8,6 +8,7 @@ pub mod local; pub mod logging; pub mod myc; pub mod network; +pub mod order; pub mod signer; pub mod sync; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -0,0 +1,763 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use radroots_events::kinds::KIND_LISTING; +use radroots_events_codec::d_tag::is_d_tag_base64url; +use radroots_events_codec::trade::RadrootsTradeListingAddress; +use serde::{Deserialize, Serialize}; + +use crate::cli::{OrderNewArgs, RecordKeyArgs}; +use crate::domain::runtime::{ + OrderDraftItemView, OrderGetView, OrderIssueView, OrderJobView, OrderListView, OrderNewView, + OrderSummaryView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::accounts; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::daemon::{self, DaemonRpcError}; + +const ORDER_DRAFT_KIND: &str = "order_draft_v1"; +const ORDER_SOURCE: &str = "local order drafts · local first"; +const ORDERS_DIR: &str = "orders/drafts"; + +static ORDER_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OrderDraftDocument { + version: u32, + kind: String, + order: OrderDraft, + #[serde(default, skip_serializing_if = "Option::is_none")] + listing_lookup: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + buyer_account_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + submission: Option<OrderDraftSubmission>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OrderDraft { + order_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + listing_addr: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + buyer_pubkey: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + seller_pubkey: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + items: Vec<OrderDraftItem>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OrderDraftItem { + bin_id: String, + bin_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OrderDraftSubmission { + job_id: String, +} + +#[derive(Debug, Clone)] +struct LoadedOrderDraft { + file: PathBuf, + updated_at_unix: u64, + document: OrderDraftDocument, +} + +pub fn scaffold(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<OrderNewView, RuntimeError> { + validate_scaffold_args(args)?; + + let selected_account = accounts::resolve_account(config)?; + let buyer_account_id = selected_account + .as_ref() + .map(|account| account.record.account_id.to_string()); + let buyer_pubkey = selected_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.clone()) + .unwrap_or_default(); + + let listing_lookup = normalize_optional(args.listing.as_deref()); + let listing_addr = normalize_optional(args.listing_addr.as_deref()).unwrap_or_default(); + let parsed_listing_addr = parse_listing_addr(listing_addr.as_str()); + let seller_pubkey = parsed_listing_addr + .as_ref() + .map(|listing| listing.seller_pubkey.clone()) + .unwrap_or_default(); + + let items = match normalize_optional(args.bin_id.as_deref()) { + Some(bin_id) => vec![OrderDraftItem { + bin_id, + bin_count: args.bin_count.unwrap_or(1), + }], + None => Vec::new(), + }; + + let order_id = next_order_id(); + let drafts_dir = drafts_dir(config); + fs::create_dir_all(&drafts_dir)?; + let file = drafts_dir.join(format!("{order_id}.toml")); + + let document = OrderDraftDocument { + version: 1, + kind: ORDER_DRAFT_KIND.to_owned(), + order: OrderDraft { + order_id: order_id.clone(), + listing_addr, + buyer_pubkey, + seller_pubkey, + items, + }, + listing_lookup, + buyer_account_id, + submission: None, + }; + fs::write(&file, scaffold_contents(&document)?)?; + + let mut view: OrderNewView = view_from_loaded( + config, + LoadedOrderDraft { + file, + updated_at_unix: now_unix(), + document, + }, + false, + ) + .into(); + view.actions + .insert(0, format!("radroots order get {}", view.order_id)); + + Ok(view) +} + +pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<OrderGetView, RuntimeError> { + let lookup = args.key.clone(); + let file = draft_lookup_path(config, lookup.as_str()); + if !file.exists() { + return Ok(OrderGetView { + state: "missing".to_owned(), + source: ORDER_SOURCE.to_owned(), + lookup: lookup.clone(), + order_id: None, + file: Some(file.display().to_string()), + listing_lookup: None, + listing_addr: None, + buyer_account_id: None, + buyer_pubkey: None, + seller_pubkey: None, + ready_for_submit: false, + items: Vec::new(), + updated_at_unix: None, + job: None, + reason: Some(format!("order draft `{lookup}` was not found")), + issues: Vec::new(), + actions: vec![ + "radroots order ls".to_owned(), + "radroots order new".to_owned(), + ], + }); + } + + match load_draft(file.as_path()) { + Ok(loaded) => Ok(view_from_loaded(config, loaded, true)), + Err(reason) => Ok(OrderGetView { + state: "error".to_owned(), + source: ORDER_SOURCE.to_owned(), + lookup, + order_id: None, + file: Some(file.display().to_string()), + listing_lookup: None, + listing_addr: None, + buyer_account_id: None, + buyer_pubkey: None, + seller_pubkey: None, + ready_for_submit: false, + items: Vec::new(), + updated_at_unix: None, + job: None, + reason: Some(reason), + issues: Vec::new(), + actions: Vec::new(), + }), + } +} + +pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> { + let dir = drafts_dir(config); + if !dir.exists() { + return Ok(OrderListView { + state: "empty".to_owned(), + source: ORDER_SOURCE.to_owned(), + count: 0, + orders: Vec::new(), + actions: vec!["radroots order new".to_owned()], + }); + } + + let mut orders = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("toml") { + continue; + } + match load_draft(path.as_path()) { + Ok(loaded) => orders.push(summary_from_loaded(config, &loaded)), + Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)), + } + } + + orders.sort_by(|left, right| { + right + .updated_at_unix + .cmp(&left.updated_at_unix) + .then_with(|| left.id.cmp(&right.id)) + }); + + let state = if orders.is_empty() { + "empty" + } else if orders.iter().any(|order| order.state == "error") { + "degraded" + } else { + "ready" + }; + let actions = if orders.is_empty() { + vec!["radroots order new".to_owned()] + } else { + Vec::new() + }; + + Ok(OrderListView { + state: state.to_owned(), + source: ORDER_SOURCE.to_owned(), + count: orders.len(), + orders, + actions, + }) +} + +fn validate_scaffold_args(args: &OrderNewArgs) -> Result<(), RuntimeError> { + match (normalize_optional(args.bin_id.as_deref()), args.bin_count) { + (None, Some(_)) => Err(RuntimeError::Config( + "`--qty` requires `--bin` when creating an order draft".to_owned(), + )), + (Some(_), Some(0)) => Err(RuntimeError::Config( + "`--qty` must be greater than zero".to_owned(), + )), + (Some(_), None) | (Some(_), Some(_)) | (None, None) => Ok(()), + } +} + +fn view_from_loaded( + config: &RuntimeConfig, + loaded: LoadedOrderDraft, + enrich_job: bool, +) -> OrderGetView { + let OrderInspection { + state, + ready_for_submit, + listing_addr, + seller_pubkey, + issues, + job, + } = inspect_document(config, &loaded.document, enrich_job); + + let mut actions = + actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); + if let Some(job) = &job { + actions.push(format!("radroots job get {}", job.job_id)); + } + + OrderGetView { + state, + source: ORDER_SOURCE.to_owned(), + lookup: loaded.document.order.order_id.clone(), + order_id: Some(loaded.document.order.order_id.clone()), + file: Some(loaded.file.display().to_string()), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr, + buyer_account_id: loaded.document.buyer_account_id.clone(), + buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), + seller_pubkey, + ready_for_submit, + items: loaded + .document + .order + .items + .iter() + .map(|item| OrderDraftItemView { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }) + .collect(), + updated_at_unix: Some(loaded.updated_at_unix), + job, + reason: None, + issues, + actions, + } +} + +fn summary_from_loaded(config: &RuntimeConfig, loaded: &LoadedOrderDraft) -> OrderSummaryView { + let OrderInspection { + state, + ready_for_submit, + listing_addr, + seller_pubkey: _, + issues, + job, + } = inspect_document(config, &loaded.document, false); + + OrderSummaryView { + id: loaded.document.order.order_id.clone(), + state, + ready_for_submit, + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr, + buyer_account_id: loaded.document.buyer_account_id.clone(), + item_count: loaded.document.order.items.len(), + updated_at_unix: loaded.updated_at_unix, + job, + issues, + } +} + +fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { + let id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_owned(); + OrderSummaryView { + id, + state: "error".to_owned(), + ready_for_submit: false, + file: path.display().to_string(), + listing_lookup: None, + listing_addr: None, + buyer_account_id: None, + item_count: 0, + updated_at_unix: modified_unix(path).unwrap_or_default(), + job: None, + issues: vec![OrderIssueView { + field: "draft".to_owned(), + message: reason, + }], + } +} + +fn inspect_document( + config: &RuntimeConfig, + document: &OrderDraftDocument, + enrich_job: bool, +) -> OrderInspection { + let listing_addr = non_empty_string(document.order.listing_addr.clone()); + let parsed_listing_addr = listing_addr + .as_deref() + .and_then(|value| parse_listing_addr(value).ok()); + let seller_pubkey = non_empty_string(document.order.seller_pubkey.clone()).or_else(|| { + parsed_listing_addr + .as_ref() + .map(|listing| listing.seller_pubkey.clone()) + }); + let issues = collect_issues(document); + let job = submission_job_view(config, document, enrich_job); + let ready_for_submit = issues.is_empty() && job.is_none(); + let state = if job.is_some() { + "submitted".to_owned() + } else if ready_for_submit { + "ready".to_owned() + } else { + "draft".to_owned() + }; + + OrderInspection { + state, + ready_for_submit, + listing_addr, + seller_pubkey, + issues, + job, + } +} + +fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> { + let mut issues = Vec::new(); + if document.version != 1 { + issues.push(issue("version", "version must be 1")); + } + if document.kind != ORDER_DRAFT_KIND { + issues.push(issue("kind", format!("kind must be `{ORDER_DRAFT_KIND}`"))); + } + if !is_valid_order_id(document.order.order_id.as_str()) { + issues.push(issue( + "order.order_id", + "order_id must look like `ord_<base64url>`", + )); + } + + match normalize_optional(Some(document.order.listing_addr.as_str())) { + Some(listing_addr) => match parse_listing_addr(listing_addr.as_str()) { + Ok(parsed) => { + if parsed.kind != KIND_LISTING { + issues.push(issue( + "order.listing_addr", + "listing_addr must reference a public NIP-99 listing", + )); + } + if let Some(seller_pubkey) = non_empty_string(document.order.seller_pubkey.clone()) + { + if seller_pubkey != parsed.seller_pubkey { + issues.push(issue( + "order.seller_pubkey", + "seller_pubkey must match listing_addr seller when both are set", + )); + } + } + } + Err(error) => issues.push(issue( + "order.listing_addr", + format!("listing_addr is invalid: {error}"), + )), + }, + None => issues.push(issue( + "order.listing_addr", + "listing_addr is required before order submit", + )), + } + + if document.order.items.is_empty() { + issues.push(issue( + "order.items", + "at least one order item is required before order submit", + )); + } + for (index, item) in document.order.items.iter().enumerate() { + if item.bin_id.trim().is_empty() { + issues.push(issue( + format!("order.items[{index}].bin_id"), + "bin_id must not be empty", + )); + } + if item.bin_count == 0 { + issues.push(issue( + format!("order.items[{index}].bin_count"), + "bin_count must be greater than zero", + )); + } + } + + if document + .buyer_account_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && document.order.buyer_pubkey.trim().is_empty() + { + issues.push(issue( + "buyer_account_id", + "buyer account or buyer_pubkey is required before order submit", + )); + } + + issues +} + +fn actions_for_document( + document: &OrderDraftDocument, + file: &Path, + issues: &[OrderIssueView], +) -> Vec<String> { + let mut actions = Vec::new(); + actions.push(format!( + "edit {} and fill the remaining draft fields", + file.display() + )); + if document.buyer_account_id.is_none() && document.order.buyer_pubkey.trim().is_empty() { + actions.push("radroots account new".to_owned()); + } + if document.order.items.is_empty() + || issues + .iter() + .any(|issue| issue.field.starts_with("order.items[")) + { + actions.push(format!("radroots order get {}", document.order.order_id)); + } + actions +} + +fn submission_job_view( + config: &RuntimeConfig, + document: &OrderDraftDocument, + enrich: bool, +) -> Option<OrderJobView> { + let job_id = document + .submission + .as_ref() + .and_then(|submission| normalize_optional(Some(submission.job_id.as_str())))?; + if !enrich || config.rpc.bridge_bearer_token.is_none() { + return Some(OrderJobView { + job_id, + state: "recorded".to_owned(), + command: None, + event_id: None, + event_addr: None, + reason: None, + }); + } + + match daemon::bridge_job(config, job_id.as_str()) { + Ok(Some(job)) => Some(OrderJobView { + job_id, + state: job.state, + command: Some(job.command), + event_id: job.event_id, + event_addr: job.event_addr, + reason: None, + }), + Ok(None) => Some(OrderJobView { + job_id, + state: "missing".to_owned(), + command: None, + event_id: None, + event_addr: None, + reason: Some("recorded job id was not found in radrootsd".to_owned()), + }), + Err(error) => Some(job_view_from_error(job_id, error)), + } +} + +fn job_view_from_error(job_id: String, error: DaemonRpcError) -> OrderJobView { + match error { + DaemonRpcError::Unconfigured(reason) + | DaemonRpcError::Unauthorized(reason) + | DaemonRpcError::MethodUnavailable(reason) => OrderJobView { + job_id, + state: "unconfigured".to_owned(), + command: None, + event_id: None, + event_addr: None, + reason: Some(reason), + }, + DaemonRpcError::External(reason) => OrderJobView { + job_id, + state: "unavailable".to_owned(), + command: None, + event_id: None, + event_addr: None, + reason: Some(reason), + }, + DaemonRpcError::InvalidResponse(reason) + | DaemonRpcError::Remote(reason) + | DaemonRpcError::UnknownJob(reason) => OrderJobView { + job_id, + state: "error".to_owned(), + command: None, + event_id: None, + event_addr: None, + reason: Some(reason), + }, + } +} + +fn load_draft(path: &Path) -> Result<LoadedOrderDraft, String> { + let contents = fs::read_to_string(path) + .map_err(|error| format!("read order draft {}: {error}", path.display()))?; + let document = toml::from_str::<OrderDraftDocument>(contents.as_str()) + .map_err(|error| format!("parse order draft {}: {error}", path.display()))?; + Ok(LoadedOrderDraft { + file: path.to_path_buf(), + updated_at_unix: modified_unix(path).unwrap_or_default(), + document, + }) +} + +fn scaffold_contents(draft: &OrderDraftDocument) -> Result<String, RuntimeError> { + let toml = toml::to_string_pretty(draft) + .map_err(|error| RuntimeError::Config(format!("render order draft: {error}")))?; + Ok(format!( + "# radroots order draft v1\n# fill listing_addr and any missing order items before submit\n\n{toml}" + )) +} + +fn drafts_dir(config: &RuntimeConfig) -> PathBuf { + config.paths.user_state_root.join(ORDERS_DIR) +} + +fn draft_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { + let candidate = PathBuf::from(lookup); + if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { + return candidate; + } + let file_name = if lookup.ends_with(".toml") { + lookup.to_owned() + } else { + format!("{lookup}.toml") + }; + drafts_dir(config).join(file_name) +} + +fn parse_listing_addr(raw: &str) -> Result<RadrootsTradeListingAddress, String> { + RadrootsTradeListingAddress::parse(raw).map_err(|error| error.to_string()) +} + +fn issue(field: impl Into<String>, message: impl Into<String>) -> OrderIssueView { + OrderIssueView { + field: field.into(), + message: message.into(), + } +} + +fn normalize_optional(value: Option<&str>) -> Option<String> { + let value = value?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +fn non_empty_string(value: String) -> Option<String> { + if value.trim().is_empty() { + None + } else { + Some(value) + } +} + +fn modified_unix(path: &Path) -> Option<u64> { + let modified = fs::metadata(path).ok()?.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .map(|value| value.as_secs()) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or_default() +} + +fn next_order_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; + format!( + "ord_{}", + encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) + ) +} + +fn is_valid_order_id(value: &str) -> bool { + let Some(encoded) = value.strip_prefix("ord_") else { + return false; + }; + encoded.len() == 22 && is_d_tag_base64url(encoded) +} + +fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut output = String::with_capacity(22); + let mut index = 0usize; + while index + 3 <= bytes.len() { + let block = ((bytes[index] as u32) << 16) + | ((bytes[index + 1] as u32) << 8) + | (bytes[index + 2] as u32); + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); + output.push(ALPHABET[(block & 0x3f) as usize] as char); + index += 3; + } + let remaining = bytes.len() - index; + if remaining == 1 { + let block = (bytes[index] as u32) << 16; + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + } else if remaining == 2 { + let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); + } + output +} + +#[derive(Debug, Clone)] +struct OrderInspection { + state: String, + ready_for_submit: bool, + listing_addr: Option<String>, + seller_pubkey: Option<String>, + issues: Vec<OrderIssueView>, + job: Option<OrderJobView>, +} + +impl From<OrderGetView> for OrderNewView { + fn from(view: OrderGetView) -> Self { + Self { + state: "draft_created".to_owned(), + source: view.source, + order_id: view.order_id.unwrap_or_default(), + file: view.file.unwrap_or_default(), + listing_lookup: view.listing_lookup, + listing_addr: view.listing_addr, + buyer_account_id: view.buyer_account_id, + buyer_pubkey: view.buyer_pubkey, + seller_pubkey: view.seller_pubkey, + ready_for_submit: view.ready_for_submit, + items: view.items, + issues: view.issues, + actions: view.actions, + } + } +} + +#[cfg(test)] +mod tests { + use super::{ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem, next_order_id}; + + #[test] + fn generated_order_id_uses_stable_prefix() { + let order_id = next_order_id(); + assert!(order_id.starts_with("ord_")); + assert_eq!(order_id.len(), 26); + } + + #[test] + fn order_draft_kind_constant_is_stable() { + let document = OrderDraftDocument { + version: 1, + kind: ORDER_DRAFT_KIND.to_owned(), + order: OrderDraft { + order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + buyer_pubkey: "a".repeat(64), + seller_pubkey: "b".repeat(64), + items: vec![OrderDraftItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + listing_lookup: Some("fresh-eggs".to_owned()), + buyer_account_id: Some("acct_demo".to_owned()), + submission: None, + }; + + let rendered = toml::to_string_pretty(&document).expect("render draft"); + assert!(rendered.contains("kind = \"order_draft_v1\"")); + assert!(rendered.contains("order_id = \"ord_AAAAAAAAAAAAAAAAAAAAAg\"")); + } +} diff --git a/tests/order.rs b/tests/order.rs @@ -0,0 +1,214 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn order_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_RPC_URL", + "RADROOTS_RPC_BEARER_TOKEN", + ] { + command.env_remove(key); + } + command +} + +fn order_test_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("order test lock") +} + +#[test] +fn order_new_creates_a_local_draft_with_selected_account_defaults() { + 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 account_id = account_json["account"]["id"].as_str().expect("account id"); + let buyer_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey"); + + let output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + "--qty", + "2", + ]) + .output() + .expect("run order new"); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("order json"); + assert_eq!(json["state"], "draft_created"); + assert_eq!(json["buyer_account_id"], account_id); + assert_eq!(json["buyer_pubkey"], buyer_pubkey); + assert_eq!( + json["seller_pubkey"], + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ); + assert_eq!(json["ready_for_submit"], true); + assert_eq!(json["items"][0]["bin_id"], "bin-1"); + assert_eq!(json["items"][0]["bin_count"], 2); + + let file = json["file"].as_str().expect("draft file"); + assert!(file.contains(".local/share/radroots/orders/drafts/ord_")); + let contents = fs::read_to_string(file).expect("read order draft"); + assert!(contents.contains("kind = \"order_draft_v1\"")); + assert!(contents.contains("listing_lookup = \"pasture-eggs\"")); + assert!(contents.contains(&format!("buyer_account_id = \"{account_id}\""))); +} + +#[test] +fn order_get_and_ls_read_local_drafts_and_report_missing() { + 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 first = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + ]) + .output() + .expect("run first order new"); + assert!(first.status.success()); + let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); + let first_order_id = first_json["order_id"].as_str().expect("first order id"); + + let second = order_command_in(dir.path()) + .args(["--json", "order", "new", "--listing", "carrots"]) + .output() + .expect("run second order new"); + assert!(second.status.success()); + let second_json: Value = serde_json::from_slice(second.stdout.as_slice()).expect("second json"); + let second_order_id = second_json["order_id"].as_str().expect("second order id"); + + let get_output = order_command_in(dir.path()) + .args(["--json", "order", "get", first_order_id]) + .output() + .expect("run order get"); + assert!(get_output.status.success()); + let get_json: Value = serde_json::from_slice(get_output.stdout.as_slice()).expect("get json"); + assert_eq!(get_json["state"], "ready"); + assert_eq!(get_json["order_id"], first_order_id); + assert_eq!(get_json["listing_lookup"], "pasture-eggs"); + + let missing_output = order_command_in(dir.path()) + .args(["--json", "order", "get", "ord_missing"]) + .output() + .expect("run missing order get"); + assert!(missing_output.status.success()); + let missing_json: Value = + serde_json::from_slice(missing_output.stdout.as_slice()).expect("missing json"); + assert_eq!(missing_json["state"], "missing"); + + let human_list = order_command_in(dir.path()) + .args(["order", "ls"]) + .output() + .expect("run human order ls"); + assert!(human_list.status.success()); + let human_text = String::from_utf8(human_list.stdout).expect("human text"); + assert!(human_text.contains("orders · 2 local drafts")); + assert!(human_text.contains(first_order_id)); + assert!(human_text.contains(second_order_id)); + + let ndjson_output = order_command_in(dir.path()) + .args(["--ndjson", "order", "ls"]) + .output() + .expect("run ndjson order ls"); + assert!(ndjson_output.status.success()); + let ndjson = String::from_utf8(ndjson_output.stdout).expect("ndjson text"); + let lines = ndjson.lines().collect::<Vec<_>>(); + assert_eq!(lines.len(), 2); + assert!(lines.iter().any(|line| line.contains(first_order_id))); + assert!(lines.iter().any(|line| line.contains(second_order_id))); +} + +#[test] +fn order_get_surfaces_recorded_job_metadata_from_the_local_draft_store() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + let drafts_dir = dir.path().join("home/.local/share/radroots/orders/drafts"); + fs::create_dir_all(&drafts_dir).expect("create drafts dir"); + let draft_path = drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"); + fs::write( + &draft_path, + r#"version = 1 +kind = "order_draft_v1" +listing_lookup = "fresh-eggs" +buyer_account_id = "acct_demo" + +[order] +order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg" +listing_addr = "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg" +buyer_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +seller_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +[[order.items]] +bin_id = "bin-1" +bin_count = 2 + +[submission] +job_id = "job_order_01" +"#, + ) + .expect("write order draft"); + + let output = order_command_in(dir.path()) + .args(["--json", "order", "get", "ord_AAAAAAAAAAAAAAAAAAAAAg"]) + .output() + .expect("run order get"); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "submitted"); + assert_eq!(json["job"]["job_id"], "job_order_01"); + assert_eq!(json["job"]["state"], "recorded"); + assert_eq!(json["ready_for_submit"], false); +}