cli

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

commit 54bc3375cbf9094bc938ec796424b329b5bedf12
parent 8061cf6cf2668d77081011d5e359af2f772ab14f
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 16:20:12 +0000

cli: expose order decision operations

- add order accept decline and status parser surfaces
- wire registry adapter service and main dispatch contracts
- add decision and status runtime output placeholders
- cover approval parser network and signer readiness behavior

Diffstat:
Msrc/domain/runtime.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 12++++++++++++
Msrc/operation_adapter.rs | 15++++++++++++++-
Msrc/operation_order.rs | 208++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/operation_registry.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/order.rs | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime/signer.rs | 29++++++++++++++++++++++++++---
Msrc/runtime_args.rs | 28++++++++++++++++++++++++++++
Msrc/target_cli.rs | 26++++++++++++++++++++++++++
Mtests/target_cli.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
10 files changed, 653 insertions(+), 25 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1204,6 +1204,106 @@ impl OrderSubmitView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderDecisionView { + pub state: String, + pub source: String, + pub order_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: 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 decision: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_kind: Option<u32>, + #[serde(default)] + pub dry_run: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acknowledged_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_relays: Vec<RelayFailureView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_mode: Option<String>, + #[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 OrderDecisionView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderStatusView { + pub state: String, + pub source: String, + pub order_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: 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>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_event_id: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reducer_issues: Vec<OrderIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connected_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_relays: Vec<RelayFailureView>, + #[serde(default)] + pub fetched_count: usize, + #[serde(default)] + pub decoded_count: usize, + #[serde(default)] + pub skipped_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderStatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderSubmitWatchView { pub submit: OrderSubmitView, pub watch: OrderWatchView, diff --git a/src/main.rs b/src/main.rs @@ -260,6 +260,15 @@ fn execute_request( TargetOperationRequest::OrderList(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderAccept(request) => { + execute_with(OrderOperationService::new(config), request) + } + TargetOperationRequest::OrderDecline(request) => { + execute_with(OrderOperationService::new(config), request) + } + TargetOperationRequest::OrderStatusGet(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderEventList(request) => { execute_with(OrderOperationService::new(config), request) } @@ -379,6 +388,9 @@ fn external_network_operation(operation_id: &str) -> bool { | "listing.publish" | "listing.archive" | "order.submit" + | "order.accept" + | "order.decline" + | "order.status.get" | "order.event.list" | "order.event.watch" ) diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1035,7 +1035,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, - MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand, + MarketProductCommand, OrderCommand, OrderEventCommand, OrderStatusCommand, TargetCommand, }; let mut input = OperationData::new(); @@ -1163,6 +1163,16 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "order_id", &args.order_id); } OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id), + OrderCommand::Accept(args) => insert_string(&mut input, "order_id", &args.order_id), + OrderCommand::Decline(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "reason", &args.reason); + } + OrderCommand::Status(status) => match &status.command { + OrderStatusCommand::Get(args) => { + insert_string(&mut input, "order_id", &args.order_id) + } + }, OrderCommand::Event(event) => match &event.command { OrderEventCommand::List(args) | OrderEventCommand::Watch(args) => { insert_string(&mut input, "order_id", &args.order_id) @@ -1259,6 +1269,9 @@ target_operation_contracts! { OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"), OrderGet => (OrderGetRequest, OrderGetResult, "order.get"), OrderList => (OrderListRequest, OrderListResult, "order.list"), + OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"), + OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"), + OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), } diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -1,16 +1,23 @@ use serde::Serialize; use serde_json::{Value, json}; -use crate::domain::runtime::{CommandDisposition, OrderSubmitView}; +use crate::domain::runtime::{ + CommandDisposition, OrderDecisionView, OrderStatusView, OrderSubmitView, +}; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, OrderEventListRequest, - OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest, - OrderGetResult, OrderListRequest, OrderListResult, OrderSubmitRequest, OrderSubmitResult, + OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult, + OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult, + OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest, OrderGetResult, + OrderListRequest, OrderListResult, OrderStatusGetRequest, OrderStatusGetResult, + OrderSubmitRequest, OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; -use crate::runtime_args::{OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs}; +use crate::runtime_args::{ + OrderDecisionArg, OrderDecisionArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, + RecordLookupArgs, +}; pub struct OrderOperationService<'a> { config: &'a RuntimeConfig, @@ -86,6 +93,97 @@ impl OperationService<OrderListRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderAcceptRequest> for OrderOperationService<'_> { + type Result = OrderAcceptResult; + + fn execute( + &self, + request: OperationRequest<OrderAcceptRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderDecisionArgs { + key: required_order_key(&request)?, + decision: OrderDecisionArg::Accept, + reason: None, + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::decide(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + decision_result::<OrderAcceptResult>(request.operation_id(), &view) + } +} + +impl OperationService<OrderDeclineRequest> for OrderOperationService<'_> { + type Result = OrderDeclineResult; + + fn execute( + &self, + request: OperationRequest<OrderDeclineRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let reason = string_input(&request, "reason").ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required `reason` input".to_owned(), + ) + })?; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderDecisionArgs { + key: required_order_key(&request)?, + decision: OrderDecisionArg::Decline, + reason: Some(reason), + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::decide(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + decision_result::<OrderDeclineResult>(request.operation_id(), &view) + } +} + +impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { + type Result = OrderStatusGetResult; + + fn execute( + &self, + request: OperationRequest<OrderStatusGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = OrderStatusArgs { + key: required_order_key(&request)?, + }; + let view = crate::runtime::order::status(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + status_result::<OrderStatusGetResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderEventListRequest> for OrderOperationService<'_> { type Result = OrderEventListResult; @@ -119,6 +217,52 @@ impl OperationService<OrderEventWatchRequest> for OrderOperationService<'_> { } } +fn decision_result<R>( + operation_id: &str, + view: &OrderDecisionView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + disposition => { + let message = view + .reason + .clone() + .unwrap_or_else(|| format!("order decision finished with state `{}`", view.state)); + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } +} + +fn status_result<R>( + operation_id: &str, + view: &OrderStatusView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + disposition => { + let message = view + .reason + .clone() + .unwrap_or_else(|| format!("order status finished with state `{}`", view.state)); + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } +} + fn serialized_target_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, @@ -272,8 +416,9 @@ mod tests { use super::OrderOperationService; use crate::operation_adapter::{ - OperationAdapter, OperationContext, OperationData, OperationRequest, OrderEventListRequest, - OrderEventWatchRequest, OrderGetRequest, OrderListRequest, OrderSubmitRequest, + OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest, + OrderDeclineRequest, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, + OrderListRequest, OrderStatusGetRequest, OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -353,6 +498,55 @@ mod tests { } #[test] + fn order_accept_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let accept = OperationRequest::new( + OperationContext::default(), + OrderAcceptRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order accept request"); + let error = service.execute(accept).expect_err("approval required"); + + assert_eq!(error.to_output_error().code, "approval_required"); + } + + #[test] + fn order_decline_requires_reason_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let decline = OperationRequest::new( + OperationContext::default(), + OrderDeclineRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order decline request"); + let error = service.execute(decline).expect_err("reason required"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "invalid_input"); + assert!(output_error.message.contains("reason")); + } + + #[test] + fn order_status_get_requires_relay_configuration() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let status = OperationRequest::new( + OperationContext::default(), + OrderStatusGetRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order status request"); + let error = service.execute(status).expect_err("status unconfigured"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "operation_unavailable"); + assert!(output_error.message.contains("configured relay")); + } + + #[test] fn order_event_list_requires_relay_configuration() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -842,6 +842,51 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false ), operation!( + "order.accept", + "radroots order accept", + "order", + "order_accept", + "OrderAcceptRequest", + "OrderAcceptResult", + "Accept a buyer order request.", + Seller, + true, + Required, + Critical, + false, + true + ), + operation!( + "order.decline", + "radroots order decline", + "order", + "order_decline", + "OrderDeclineRequest", + "OrderDeclineResult", + "Decline a buyer order request.", + Seller, + true, + Required, + High, + false, + true + ), + operation!( + "order.status.get", + "radroots order status get", + "order", + "order_status_get", + "OrderStatusGetRequest", + "OrderStatusGetResult", + "Get reducer-derived order status.", + Any, + false, + None, + Low, + false, + false + ), + operation!( "order.event.list", "radroots order event list", "order", @@ -946,6 +991,9 @@ mod tests { "order.submit", "order.get", "order.list", + "order.accept", + "order.decline", + "order.status.get", "order.event.list", "order.event.watch", ]; @@ -977,6 +1025,8 @@ mod tests { "basket.item.remove", "basket.quote.create", "order.submit", + "order.accept", + "order.decline", ]; const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; @@ -989,7 +1039,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 53); + assert_eq!(OPERATION_REGISTRY.len(), 56); } #[test] @@ -1036,6 +1086,8 @@ mod tests { "listing.publish", "listing.archive", "order.submit", + "order.accept", + "order.decline", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -27,9 +27,9 @@ use radroots_trade::order::canonicalize_active_order_request_for_signer; use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ - OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, - OrderIssueView, OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, OrderWatchView, - RelayFailureView, + OrderCancelView, OrderDecisionView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView, + OrderHistoryView, OrderIssueView, OrderListView, OrderNewView, OrderStatusView, + OrderSubmitView, OrderSummaryView, OrderWatchView, RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -40,13 +40,16 @@ use crate::runtime::direct_relay::{ }; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ - OrderDraftCreateArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderDecisionArgs, OrderDraftCreateArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, + RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts · local first"; const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key"; +const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key"; const ORDER_EVENT_LIST_SOURCE: &str = "direct Nostr relay fetch · selected seller identity"; +const ORDER_STATUS_SOURCE: &str = "direct Nostr relay status fetch · active order reducer"; const ORDER_EVENT_WATCH_UNAVAILABLE_REASON: &str = "relay-backed order event watch is not implemented"; const ORDERS_DIR: &str = "orders/drafts"; @@ -635,6 +638,125 @@ pub fn history( Ok(order_history_from_receipt(seller_pubkey, order_id, receipt)) } +pub fn decide( + config: &RuntimeConfig, + args: &OrderDecisionArgs, +) -> Result<OrderDecisionView, RuntimeError> { + let decision_reason = args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()); + if config.output.dry_run { + return Ok(OrderDecisionView { + state: "dry_run".to_owned(), + source: ORDER_DECISION_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + decision: args.decision.as_str().to_owned(), + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + dry_run: true, + target_relays: config.relay.urls.clone(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: Some(match decision_reason { + Some(reason) => format!( + "dry run requested; seller order decision publication skipped with reason `{reason}`" + ), + None => "dry run requested; seller order decision publication skipped".to_owned(), + }), + issues: Vec::new(), + actions: vec![format!("radroots order status get {}", args.key)], + }); + } + + Ok(OrderDecisionView { + state: "unavailable".to_owned(), + source: ORDER_DECISION_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + decision: args.decision.as_str().to_owned(), + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + dry_run: false, + target_relays: config.relay.urls.clone(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: Some(match decision_reason { + Some(reason) => { + format!( + "seller order decision publication is not implemented for reason `{reason}`" + ) + } + None => "seller order decision publication is not implemented".to_owned(), + }), + issues: Vec::new(), + actions: Vec::new(), + }) +} + +pub fn status( + config: &RuntimeConfig, + args: &OrderStatusArgs, +) -> Result<OrderStatusView, RuntimeError> { + if config.relay.urls.is_empty() { + return Ok(OrderStatusView { + state: "unconfigured".to_owned(), + source: ORDER_STATUS_SOURCE.to_owned(), + order_id: args.key.clone(), + request_event_id: None, + decision_event_id: None, + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + last_event_id: None, + reducer_issues: Vec::new(), + target_relays: Vec::new(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + reason: Some("order status get requires at least one configured relay".to_owned()), + actions: Vec::new(), + }); + } + + Ok(OrderStatusView { + state: "unavailable".to_owned(), + source: ORDER_STATUS_SOURCE.to_owned(), + order_id: args.key.clone(), + request_event_id: None, + decision_event_id: None, + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + last_event_id: None, + reducer_issues: Vec::new(), + target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + reason: Some("order status reducer fetch is not implemented".to_owned()), + actions: Vec::new(), + }) +} + pub fn cancel( config: &RuntimeConfig, args: &RecordLookupArgs, diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -4,7 +4,9 @@ use crate::domain::runtime::{ }; use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend}; -use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_ORDER_REQUEST}; +use radroots_events::kinds::{ + KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, +}; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, @@ -272,7 +274,7 @@ fn deferred_myc_binding_status() -> SignerBindingStatusView { } } -fn cli_write_kinds() -> [CliWriteKind; 4] { +fn cli_write_kinds() -> [CliWriteKind; 6] { [ CliWriteKind { command: "farm profile publish", @@ -290,6 +292,14 @@ fn cli_write_kinds() -> [CliWriteKind; 4] { command: "order submit", event_kind: KIND_TRADE_ORDER_REQUEST, }, + CliWriteKind { + command: "order accept", + event_kind: KIND_TRADE_ORDER_DECISION, + }, + CliWriteKind { + command: "order decline", + event_kind: KIND_TRADE_ORDER_DECISION, + }, ] } @@ -333,7 +343,7 @@ fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static s mod tests { use radroots_events::kinds::KIND_TRADE_DISCOUNT_DECLINE; - use super::{KIND_TRADE_ORDER_REQUEST, cli_write_kinds}; + use super::{KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, cli_write_kinds}; #[test] fn order_submit_readiness_uses_active_order_request_kind() { @@ -345,4 +355,17 @@ mod tests { assert_eq!(write_kind.event_kind, KIND_TRADE_ORDER_REQUEST); assert_ne!(write_kind.event_kind, KIND_TRADE_DISCOUNT_DECLINE); } + + #[test] + fn order_decision_readiness_uses_active_order_decision_kind() { + for command in ["order accept", "order decline"] { + let write_kind = cli_write_kinds() + .into_iter() + .find(|kind| kind.command == command) + .expect("order decision readiness"); + + assert_eq!(write_kind.event_kind, KIND_TRADE_ORDER_DECISION); + assert_ne!(write_kind.event_kind, KIND_TRADE_DISCOUNT_DECLINE); + } + } } diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -176,6 +176,34 @@ pub struct OrderSubmitArgs { pub idempotency_key: Option<String>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderDecisionArg { + Accept, + Decline, +} + +impl OrderDecisionArg { + pub fn as_str(self) -> &'static str { + match self { + Self::Accept => "accepted", + Self::Decline => "declined", + } + } +} + +#[derive(Debug, Clone)] +pub struct OrderDecisionArgs { + pub key: String, + pub decision: OrderDecisionArg, + pub reason: Option<String>, + pub idempotency_key: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct OrderStatusArgs { + pub key: String, +} + #[derive(Debug, Clone)] pub struct OrderWatchArgs { pub key: String, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -172,6 +172,11 @@ impl TargetCommand { OrderCommand::Submit(_) => "order.submit", OrderCommand::Get(_) => "order.get", OrderCommand::List => "order.list", + OrderCommand::Accept(_) => "order.accept", + OrderCommand::Decline(_) => "order.decline", + OrderCommand::Status(status) => match &status.command { + OrderStatusCommand::Get(_) => "order.status.get", + }, OrderCommand::Event(event) => match &event.command { OrderEventCommand::List(_) => "order.event.list", OrderEventCommand::Watch(_) => "order.event.watch", @@ -678,6 +683,9 @@ pub enum OrderCommand { Submit(OrderSubmitArgs), Get(OrderKeyArgs), List, + Accept(OrderKeyArgs), + Decline(OrderDeclineArgs), + Status(OrderStatusArgs), Event(OrderEventArgs), } @@ -692,6 +700,24 @@ pub struct OrderKeyArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderDeclineArgs { + pub order_id: Option<String>, + #[arg(long)] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct OrderStatusArgs { + #[command(subcommand)] + pub command: OrderStatusCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderStatusCommand { + Get(OrderKeyArgs), +} + +#[derive(Debug, Clone, Args)] pub struct OrderEventArgs { #[command(subcommand)] pub command: OrderEventCommand, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -111,21 +111,60 @@ fn removed_command_families_are_rejected_publicly() { } #[test] -fn seller_order_decision_commands_are_deferred() { - for args in [ - ["order", "accept", "ord_deferred"].as_slice(), - ["order", "decline", "ord_deferred"].as_slice(), - ["order", "decision", "accept", "ord_deferred"].as_slice(), +fn seller_order_decision_and_status_commands_are_public() { + for (operation_id, args) in [ + ( + "order.accept", + [ + "--format", + "json", + "--dry-run", + "order", + "accept", + "ord_public", + ] + .as_slice(), + ), + ( + "order.decline", + [ + "--format", + "json", + "--dry-run", + "order", + "decline", + "ord_public", + "--reason", + "out_of_stock", + ] + .as_slice(), + ), + ( + "order.status.get", + ["--format", "json", "order", "status", "get", "ord_public"].as_slice(), + ), ] { let output = radroots() .args(args) .output() - .expect("run deferred seller order decision command"); + .expect("run seller order command"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); - assert!(!output.status.success(), "`{args:?}` should be rejected"); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("unrecognized subcommand")); + assert_eq!(value["operation_id"], operation_id); + assert_ne!( + String::from_utf8(output.stderr).expect("utf8 stderr"), + "unrecognized subcommand" + ); } + + let output = radroots() + .args(["order", "decision", "accept", "ord_deferred"]) + .output() + .expect("run removed nested decision command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("unrecognized subcommand")); } #[test] @@ -517,6 +556,19 @@ fn online_requires_relay_for_external_network_operations() { ["--format", "json", "--online", "order", "event", "list"].as_slice(), ), ( + "order.status.get", + [ + "--format", + "json", + "--online", + "order", + "status", + "get", + "ord_missing", + ] + .as_slice(), + ), + ( "order.event.watch", [ "--format", @@ -979,6 +1031,12 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { &["listing", "archive", "missing-listing.toml"], ); assert_required_approval_token_rejected(&sandbox, "order.submit", &["order", "submit"]); + assert_required_approval_token_rejected(&sandbox, "order.accept", &["order", "accept"]); + assert_required_approval_token_rejected( + &sandbox, + "order.decline", + &["order", "decline", "--reason", "out_of_stock"], + ); } fn assert_required_approval_token_rejected(